Here we go π
Iβll give you a full, ready-to-use structure for mofidtech_blog with Django + PostgreSQL + Docker + docker-compose + Nginx + Gunicorn.
Iβll organize it like this:
.envREADME.md# pick a folder
mkdir mofidtech_blog && cd mofidtech_blog
# create & activate venv (Windows)
python -m venv .venv
.venv\Scripts\activate
# macOS/Linux:
# python3 -m venv .venv
# source .venv/bin/activatepip install --upgrade pip
pip install djangodjango-admin startproject mofidtech_blog .
python manage.py startapp blog
After adding the required folders and files the project structure should look like :
mofidtech_blog/
βββ manage.py
βββ requirements.txt
βββ README.md
βββ docker-compose.yml # Dev (runserver)
βββ docker-compose.prod.yml # Prod (Gunicorn + Nginx)
βββ Dockerfile
βββ .env
βββ nginx/
β βββ default.conf
βββ mofidtech_blog/
βββ __init__.py
βββ asgi.py
βββ wsgi.py
βββ urls.py
βββ settings/
βββ __init__.py
βββ base.py
βββ dev.py
βββ prod.py
βββ blog/
βββ __init__.py
βββ admin.py
βββ apps.py
βββ forms.py
βββ models.py
βββ urls.py
βββ views.py
βββ templatetags/
β βββ __init__.py
β βββ blog_extras.py
βββ templates/
βββ base.html
βββ partials/
β βββ _pagination.html
βββ blog/
β βββ article_list.html
β βββ article_detail.html
β βββ category_list.html
β βββ tag_list.html
βββ static_pages/
βββ home.html
βββ about.html
βββ contact.html
You can adjust names / paths as you like, but this is a solid starting point.
requirements.txtDjango==4.2.*
gunicorn==21.2.0
psycopg2-binary==2.9.9
Pillow==11.0.0
(Django 4.2 is the current LTS.)
mofidtech_blog/settings/__init__.py# Allows `DJANGO_SETTINGS_MODULE=mofidtech_blog.settings.dev` or `.prod`
mofidtech_blog/settings/base.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ.get("SECRET_KEY", "unsafe-dev-key")
DEBUG = False
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"blog",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "mofidtech_blog.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"], # optional global templates dir
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "mofidtech_blog.wsgi.application"
# Database (override in dev/prod)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "mofidtech_blog"),
"USER": os.environ.get("POSTGRES_USER", "mofidtech"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "mofidtech"),
"HOST": os.environ.get("POSTGRES_HOST", "db"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static & media
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [
BASE_DIR / "static",
]
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "mediafiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Email backend (override in prod)
EMAIL_BACKEND = os.environ.get(
"EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
)
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@mofidtech.local")
LOGIN_REDIRECT_URL = "blog:home"
LOGOUT_REDIRECT_URL = "blog:home"
mofidtech_blog/settings/dev.py
from .base import *
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Optional: different DB for dev if needed (by env)
mofidtech_blog/settings/prod.py
from .base import *
DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = False # set True when using HTTPS
SESSION_COOKIE_SECURE = False # set True when using HTTPS
# Add any extra hardening here (HSTS, etc.)
mofidtech_blog/urls.pyfrom django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import RedirectView
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")), # login, logout, password reset
path("", include(("blog.urls", "blog"), namespace="blog")),
path("", RedirectView.as_view(pattern_name="blog:home", permanent=False)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
blog/models.pyfrom django.conf import settings
from django.db import models
from django.utils.text import slugify
from django.urls import reverse
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=120, unique=True)
class Meta:
verbose_name_plural = "Categories"
ordering = ["name"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("blog:category_detail", args=[self.slug])
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=60, unique=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("blog:tag_detail", args=[self.slug])
class Article(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
cover_image = models.ImageField(upload_to="articles/covers/", blank=True, null=True)
short_description = models.TextField(max_length=300)
body = models.TextField()
category = models.ForeignKey(
Category, on_delete=models.PROTECT, related_name="articles"
)
tags = models.ManyToManyField(Tag, related_name="articles", blank=True)
# SEO
meta_title = models.CharField(max_length=255, blank=True)
meta_description = models.CharField(max_length=255, blank=True)
is_published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="articles"
)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)[:220]
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("blog:article_detail", args=[self.slug])
class Comment(models.Model):
article = models.ForeignKey(
Article, on_delete=models.CASCADE, related_name="comments"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments"
)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
is_approved = models.BooleanField(default=False)
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"Comment by {self.user} on {self.article}"
class Profile(models.Model):
"""
Simple profile so users can manage their own data.
You can extend it later (avatar, social links, etc.)
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile"
)
bio = models.TextField(blank=True)
def __str__(self):
return f"Profile of {self.user.username}"
(You can add signals to auto-create profiles if you want; for simplicity Iβll skip them here.)
blog/admin.pyfrom django.contrib import admin
from .models import Category, Tag, Article, Comment, Profile
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
prepopulated_fields = {"slug": ("name",)}
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
prepopulated_fields = {"slug": ("name",)}
class CommentInline(admin.TabularInline):
model = Comment
extra = 0
fields = ("user", "content", "is_approved", "created_at")
readonly_fields = ("created_at",)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "category", "is_published", "created_at")
list_filter = ("is_published", "category", "tags")
search_fields = ("title", "short_description", "body")
prepopulated_fields = {"slug": ("title",)}
inlines = [CommentInline]
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ("article", "user", "is_approved", "created_at")
list_filter = ("is_approved", "created_at")
search_fields = ("content",)
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ("user",)
blog/forms.pyfrom django import forms
from .models import Comment, Profile
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ["content"]
widgets = {
"content": forms.Textarea(
attrs={"rows": 3, "placeholder": "Write your comment..."}
)
}
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = ["bio"]
blog/templatetags/blog_extras.pyfrom django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def querystring(context, **kwargs):
"""
Helper to rebuild querystring in templates (for pagination + filters).
Usage: ?{% querystring page=page_obj.next_page_number %}
"""
request = context["request"]
updated = request.GET.copy()
for key, value in kwargs.items():
if value is None:
updated.pop(key, None)
else:
updated[key] = value
return updated.urlencode()
blog/views.pyfrom django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import (
ListView,
DetailView,
TemplateView,
UpdateView,
)
from django.utils.decorators import method_decorator
from django.urls import reverse_lazy
from .models import Article, Category, Tag, Profile
from .forms import CommentForm, ProfileForm
class HomeView(ListView):
"""
Homepage: latest articles + featured (simple is_published filter).
"""
model = Article
template_name = "static_pages/home.html"
context_object_name = "articles"
paginate_by = 5
def get_queryset(self):
return Article.objects.filter(is_published=True).select_related(
"category", "author"
)[:20]
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["featured_articles"] = (
Article.objects.filter(is_published=True)
.order_by("-created_at")[:3]
.select_related("category", "author")
)
ctx["categories"] = Category.objects.all()
ctx["tags"] = Tag.objects.all()
return ctx
class ArticleListView(ListView):
model = Article
template_name = "blog/article_list.html"
context_object_name = "articles"
paginate_by = 5
def get_queryset(self):
qs = Article.objects.filter(is_published=True).select_related(
"category", "author"
)
# Search
q = self.request.GET.get("q")
if q:
qs = qs.filter(
models.Q(title__icontains=q) | models.Q(body__icontains=q)
).distinct()
# Filter by category (slug in GET ?category=)
category_slug = self.request.GET.get("category")
if category_slug:
qs = qs.filter(category__slug=category_slug)
# Filter by tag (?tag=)
tag_slug = self.request.GET.get("tag")
if tag_slug:
qs = qs.filter(tags__slug=tag_slug)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["categories"] = Category.objects.all()
ctx["tags"] = Tag.objects.all()
ctx["current_category"] = self.request.GET.get("category")
ctx["current_tag"] = self.request.GET.get("tag")
ctx["query"] = self.request.GET.get("q", "")
return ctx
class ArticleDetailView(DetailView):
model = Article
template_name = "blog/article_detail.html"
context_object_name = "article"
def get_queryset(self):
# Only published articles in public views
return (
Article.objects.filter(is_published=True)
.select_related("category", "author")
.prefetch_related("tags", "comments__user")
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
article = self.object
ctx["comments"] = article.comments.filter(is_approved=True)
ctx["comment_form"] = CommentForm()
return ctx
def post(self, request, *args, **kwargs):
"""
Handle comment submission.
"""
self.object = self.get_object()
if not request.user.is_authenticated:
return redirect("login")
form = CommentForm(request.POST)
if form.is_valid():
comment = form.save(commit=False)
comment.article = self.object
comment.user = request.user
# is_approved defaults to False: moderation by admin
comment.save()
return redirect(self.object.get_absolute_url())
class CategoryDetailView(ArticleListView):
"""
Reuse ArticleListView logic but pre-filter by category slug from URL.
"""
def get_queryset(self):
category_slug = self.kwargs["slug"]
qs = (
Article.objects.filter(is_published=True, category__slug=category_slug)
.select_related("category", "author")
.prefetch_related("tags")
)
q = self.request.GET.get("q")
if q:
qs = qs.filter(
models.Q(title__icontains=q) | models.Q(body__icontains=q)
).distinct()
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["category_obj"] = get_object_or_404(Category, slug=self.kwargs["slug"])
return ctx
class TagDetailView(ArticleListView):
def get_queryset(self):
tag_slug = self.kwargs["slug"]
qs = (
Article.objects.filter(is_published=True, tags__slug=tag_slug)
.select_related("category", "author")
.prefetch_related("tags")
)
q = self.request.GET.get("q")
if q:
qs = qs.filter(
models.Q(title__icontains=q) | models.Q(body__icontains=q)
).distinct()
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["tag_obj"] = get_object_or_404(Tag, slug=self.kwargs["slug"])
return ctx
class AboutView(TemplateView):
template_name = "static_pages/about.html"
class ContactView(TemplateView):
template_name = "static_pages/contact.html"
class ProfileUpdateView(LoginRequiredMixin, UpdateView):
model = Profile
form_class = ProfileForm
template_name = "blog/profile_form.html"
success_url = reverse_lazy("blog:profile")
def get_object(self, queryset=None):
# Ensure each user edits only their profile
profile, created = Profile.objects.get_or_create(user=self.request.user)
return profile
blog/urls.pyfrom django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("articles/", views.ArticleListView.as_view(), name="article_list"),
path("articles/<slug:slug>/", views.ArticleDetailView.as_view(), name="article_detail"),
path("categories/<slug:slug>/", views.CategoryDetailView.as_view(), name="category_detail"),
path("tags/<slug:slug>/", views.TagDetailView.as_view(), name="tag_detail"),
path("about/", views.AboutView.as_view(), name="about"),
path("contact/", views.ContactView.as_view(), name="contact"),
path("profile/", views.ProfileUpdateView.as_view(), name="profile"),
]
blog/templates/base.html (Bootstrap 5 skeleton + SEO blocks)<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>
{% block meta_title %}
{% if article.meta_title %}{{ article.meta_title }}{% else %}MofidTech Blog{% endif %}
{% endblock %}
</title>
<meta name="description" content="{% block meta_description %}{% if article.meta_description %}{{ article.meta_description }}{% else %}MofidTech blog about programming, tech, AI, IoT and more.{% endif %}{% endblock %}">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Bootstrap 5 CSS from CDN (you can vendor it later) -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
{% load static %}
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand" href="{% url 'blog:home' %}">MofidTech Blog</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'blog:article_list' %}">Articles</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'blog:about' %}">About</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'blog:contact' %}">Contact</a>
</li>
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'blog:profile' %}">Profile</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'password_reset' %}">Reset Password</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main class="container">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} mb-3">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="bg-light mt-5 py-3">
<div class="container text-center">
<small>© {{ now|date:"Y" }} MofidTech Blog. All rights reserved.</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
blog/templates/static_pages/home.html
{% extends "base.html" %}
{% block content %}
<h1 class="mb-4">Welcome to MofidTech Blog</h1>
<h2 class="h4">Featured posts</h2>
<div class="row mb-4">
{% for article in featured_articles %}
<div class="col-md-4">
<div class="card h-100">
{% if article.cover_image %}
<img src="{{ article.cover_image.url }}" class="card-img-top" alt="{{ article.title }}">
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ article.title }}</h5>
<p class="card-text">{{ article.short_description|truncatechars:120 }}</p>
<a class="btn btn-primary mt-auto" href="{{ article.get_absolute_url }}">Read more</a>
</div>
</div>
</div>
{% empty %}
<p>No featured posts yet.</p>
{% endfor %}
</div>
<h2 class="h4">Latest posts</h2>
<div class="row">
{% for article in articles %}
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<a href="{{ article.get_absolute_url }}">{{ article.title }}</a>
</h5>
<p class="card-text text-muted">
{{ article.created_at|date:"M d, Y" }} Β· {{ article.category.name }}
</p>
<p class="card-text">{{ article.short_description|truncatechars:150 }}</p>
</div>
</div>
</div>
{% empty %}
<p>No articles yet.</p>
{% endfor %}
</div>
{% endblock %}
blog/templates/blog/article_list.html
{% extends "base.html" %}
{% load blog_extras %}
{% block content %}
<h1 class="mb-4">Articles</h1>
<form method="get" class="row g-2 mb-3">
<div class="col-md-4">
<input
type="text"
name="q"
class="form-control"
placeholder="Search..."
value="{{ query }}"
/>
</div>
<div class="col-md-3">
<select name="category" class="form-select">
<option value="">All categories</option>
{% for cat in categories %}
<option value="{{ cat.slug }}" {% if current_category == cat.slug %}selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<select name="tag" class="form-select">
<option value="">All tags</option>
{% for tag in tags %}
<option value="{{ tag.slug }}" {% if current_tag == tag.slug %}selected{% endif %}>{{ tag.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" type="submit">Filter</button>
</div>
</form>
{% for article in articles %}
<div class="card mb-3">
<div class="card-body">
<h2 class="h4">
<a href="{{ article.get_absolute_url }}">{{ article.title }}</a>
</h2>
<p class="text-muted">
{{ article.created_at|date:"M d, Y" }} Β· {{ article.category.name }}
</p>
<p>{{ article.short_description|truncatechars:200 }}</p>
</div>
</div>
{% empty %}
<p>No articles found.</p>
{% endfor %}
{% include "partials/_pagination.html" %}
{% endblock %}
blog/templates/blog/article_detail.html
{% extends "base.html" %}
{% block meta_title %}
{{ article.meta_title|default:article.title }}
{% endblock %}
{% block meta_description %}
{{ article.meta_description|default:article.short_description|truncatechars:155 }}
{% endblock %}
{% block content %}
<article class="mb-4">
<h1>{{ article.title }}</h1>
<p class="text-muted">
{{ article.created_at|date:"M d, Y" }} Β· {{ article.category.name }}
</p>
{% if article.cover_image %}
<img src="{{ article.cover_image.url }}" alt="{{ article.title }}" class="img-fluid mb-3" />
{% endif %}
<div>
{{ article.body|linebreaks }}
</div>
<p class="mt-3">
Tags:
{% for tag in article.tags.all %}
<a href="{{ tag.get_absolute_url }}" class="badge bg-secondary">{{ tag.name }}</a>
{% empty %}
<span class="text-muted">No tags.</span>
{% endfor %}
</p>
</article>
<section class="mt-5">
<h2 class="h5">Comments</h2>
{% for comment in comments %}
<div class="border rounded p-2 mb-2">
<strong>{{ comment.user.username }}</strong>
<span class="text-muted ms-2">{{ comment.created_at|date:"M d, Y H:i" }}</span>
<p class="mb-0">{{ comment.content|linebreaks }}</p>
</div>
{% empty %}
<p>No comments yet.</p>
{% endfor %}
</section>
<section class="mt-4">
<h3 class="h5">Leave a comment</h3>
{% if user.is_authenticated %}
<form method="post">
{% csrf_token %}
{{ comment_form.as_p }}
<button class="btn btn-primary" type="submit">Submit</button>
</form>
<p class="text-muted mt-2">Your comment will appear once approved by a moderator.</p>
{% else %}
<p>You must <a href="{% url 'login' %}?next={{ request.path }}">log in</a> to comment.</p>
{% endif %}
</section>
{% endblock %}
blog/templates/partials/_pagination.html
{% load blog_extras %}
{% if is_paginated %}
<nav aria-label="Pagination">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item">
<a
class="page-link"
href="?{% querystring page=page_obj.previous_page_number %}"
>« Prev</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">« Prev</span>
</li>
{% endif %}
{% for num in paginator.page_range %}
{% if num == page_obj.number %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
{% else %}
<li class="page-item">
<a class="page-link" href="?{% querystring page=num %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a
class="page-link"
href="?{% querystring page=page_obj.next_page_number %}"
>Next »</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next »</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
blog/templates/static_pages/about.html
{% extends "base.html" %}
{% block content %}
<h1>About MofidTech</h1>
<p>
MofidTech is a blog dedicated to programming languages, modern technologies,
AI, IoT, embedded systems, and more. Our mission is to provide clear and
practical content for learners and professionals.
</p>
{% endblock %}
blog/templates/static_pages/contact.html
{% extends "base.html" %}
{% block content %}
<h1>Contact</h1>
<p>
For questions or collaboration, please contact us at:
<a href="mailto:contact@mofidtech.local">contact@mofidtech.local</a>.
</p>
{% endblock %}
DockerfileFROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# System deps (for psycopg2 + Pillow)
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
libjpeg62-turbo-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/
# Create directories for static & media
RUN mkdir -p /app/staticfiles /app/mediafiles
# Default is Gunicorn (used in production)
CMD ["gunicorn", "mofidtech_blog.wsgi:application", "--bind", "0.0.0.0:8000"]
docker-compose.yml (Dev β Django runserver)version: "3.9"
services:
web:
build: .
command: >
sh -c "python manage.py migrate &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- .:/app
- static_volume:/app/staticfiles
- media_volume:/app/mediafiles
env_file:
- .env
environment:
- DJANGO_SETTINGS_MODULE=mofidtech_blog.settings.dev
ports:
- "8000:8000"
depends_on:
- db
db:
image: postgres:16-alpine
env_file:
- .env
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
static_volume:
media_volume:
Run for development:
docker-compose up --build
docker-compose.prod.yml (Prod β Gunicorn + Nginx)version: "3.9"
services:
web:
build: .
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput &&
gunicorn mofidtech_blog.wsgi:application --bind 0.0.0.0:8000"
env_file:
- .env
environment:
- DJANGO_SETTINGS_MODULE=mofidtech_blog.settings.prod
volumes:
- static_volume:/app/staticfiles
- media_volume:/app/mediafiles
depends_on:
- db
db:
image: postgres:16-alpine
env_file:
- .env
volumes:
- postgres_data:/var/lib/postgresql/data
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
depends_on:
- web
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- static_volume:/staticfiles
- media_volume:/mediafiles
volumes:
postgres_data:
static_volume:
media_volume:
To run in production mode (on a server):
docker-compose -f docker-compose.prod.yml up --build -d
nginx/default.confserver {
listen 80;
server_name mofidtech.local;
# Max upload size
client_max_body_size 10M;
# Static files
location /static/ {
alias /staticfiles/;
}
# Media files
location /media/ {
alias /mediafiles/;
}
# Proxy all other requests to Django (Gunicorn)
location / {
proxy_pass http://web:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
web service by its docker network hostname: web..env# Django
SECRET_KEY=change-me-to-a-strong-secret-key
DEBUG=1
ALLOWED_HOSTS=localhost,127.0.0.1,mofidtech.local
# Database
POSTGRES_DB=mofidtech_blog
POSTGRES_USER=mofidtech
POSTGRES_PASSWORD=mofidtech_password
POSTGRES_HOST=db
POSTGRES_PORT=5432
# Email
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=noreply@mofidtech.local
Copy this to .env and adjust values.
# MofidTech Blog
A Django-based blog application, containerized with Docker and ready for deployment
behind Nginx + Gunicorn.
## 1. Features
- Articles with:
- Title, slug, cover image
- Short description, rich text body
- Category (one main category)
- Tags (many-to-many)
- SEO fields: `meta_title`, `meta_description`
- `created_at`, `updated_at`, `is_published`
- Categories & tags
- User authentication (Django auth):
- Register (using default Django auth views / templates)
- Login, logout
- Password reset
- Permissions:
- Staff/admin create/edit/delete posts via Django admin
- Normal users can read posts and edit their own profile (bio)
- Comments:
- Authenticated users can comment
- Comments require approval (`is_approved`) before being shown
- Search & filtering:
- Search by title/body
- Filter by category & tag
- Pagination on list views
- Static pages:
- Home page with featured + latest posts
- About & Contact pages
## 2. Project structure
See top-level description in this repo. Main components:
- `mofidtech_blog/` β project settings, URLs, WSGI
- `blog/` β main blog app (models, views, templates)
- `nginx/default.conf` β Nginx reverse proxy config
- `Dockerfile` β Django image
- `docker-compose.yml` β dev stack (Django + Postgres)
- `docker-compose.prod.yml` β prod stack (Django + Postgres + Nginx)
- `.env.example` β environment variables template
## 3. Environment variables
Copy `.env.example` to `.env` and adjust:
```bash
cp .env.example .env
Key variables:
SECRET_KEY: Django secret key (use a strong, unique value)ALLOWED_HOSTS: comma-separated list of hosts/domainsPOSTGRES_*: database credentials (shared between Django and Postgres)EMAIL_BACKEND, DEFAULT_FROM_EMAIL: email setup; for dev we use console backend.docker-compose up --build
This will:
runserver on web serviceAccess the site at: http://localhost:8000
If you want manual control:
docker-compose run --rm web python manage.py migratedocker-compose run --rm web python manage.py createsuperuserThen access the admin at http://localhost:8000/admin/.
On your server (after copying the project and .env):
docker-compose -f docker-compose.prod.yml up --build -dThis will:
/app/staticfilesweb containerweb/static/ and /media/ directly from dedicated volumesAccess the site at http://YOUR_SERVER_IP/ (or your domain).
Django is configured with:
STATIC_ROOT = BASE_DIR / "staticfiles"MEDIA_ROOT = BASE_DIR / "mediafiles"In production:
static_volume:/app/staticfiles (web) and /staticfiles (nginx)media_volume:/app/mediafiles (web) and /mediafiles (nginx)/static/ β /staticfiles//media/ β /mediafiles/Edit .env and then recreate containers:
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up --build -dsettings/base.py, settings/dev.py, settings/prod.pyListView, DetailView, TemplateView, UpdateView)blog: namespace)web service):db service)runserver on port 8000db service):postgres_data Docker volume/app/staticfiles/app/mediafilesnginx service, prod only):/static/ and /media/ directly from volumesweb:8000 (Gunicorn)If something is ambiguous (e.g. exact layout of Bootstrap styles, profile fields), the code is intentionally minimal and easy to extend. You can plug in CKEditor later for rich text, add more profile fields, tags navigation, etc.
If you want, next step I can:
body field, orJust tell me in the comments below.