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:
- Project structure
- Django code (settings, models, views, urls, templates)
- Docker & docker-compose (dev + prod)
- Nginx config
.envREADME.md- Start the stack
- Production deployment
- Code style & structure
- Quick explanation of how everything fits together
1. Project structure
1.1. Prereqs
- Install: Python 3.11+
- Install : Docker
1.2. Create a project folder & virtual env
# 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/activate1.3. Install Django
pip install --upgrade pip
pip install django1.4. Start the Django project and create the app
django-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.
2. Django code
2.1 requirements.txt
Django==4.2.*
gunicorn==21.2.0
psycopg2-binary==2.9.9
Pillow==11.0.0
(Django 4.2 is the current LTS.)
2.2 Settings package
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.)
2.3 Project URLs
mofidtech_blog/urls.py
from 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)
2.4 Models
blog/models.py
from 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.)
2.5 Admin
blog/admin.py
from 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",)
2.6 Forms
blog/forms.py
from 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"]
2.7 Template tags (for active filters etc. if needed)
blog/templatetags/blog_extras.py
from 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()
2.8 Views
blog/views.py
from 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
2.9 App URLs
blog/urls.py
from 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"),
]
2.10 Templates
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 %}
3. Docker & docker-compose
3.1 Dockerfile
FROM 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"]
3.2 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
3.3 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
4. Nginx configuration
nginx/default.conf
server {
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;
}
}
- Nginx sees the
webservice by its docker network hostname:web. - Static and media directories are shared via Docker volumes.
5. .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.
6. README.md
# 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.
7. Development (with Docker)
7.1 Start the stack
docker-compose up --build
This will:
- Build the Django image
- Start Postgres
- Run migrations and
runserveronwebservice
Access the site at: http://localhost:8000
7.2 Run migrations manually (optional)
If you want manual control:
docker-compose run --rm web python manage.py migrate7.3 Create superuser
docker-compose run --rm web python manage.py createsuperuserThen access the admin at http://localhost:8000/admin/.
8. Production deployment
8.1 Build & run
On your server (after copying the project and .env):
docker-compose -f docker-compose.prod.yml up --build -dThis will:
- Run migrations
- Collect static files to
/app/staticfiles - Start Gunicorn on port 8000 in the
webcontainer - Start Nginx on port 80, proxying to
web - Serve
/static/and/media/directly from dedicated volumes
Access the site at http://YOUR_SERVER_IP/ (or your domain).
8.2 Static & media files
Django is configured with:
STATIC_ROOT = BASE_DIR / "staticfiles"MEDIA_ROOT = BASE_DIR / "mediafiles"
In production:
- These are mounted as Docker volumes:
static_volume:/app/staticfiles(web) and/staticfiles(nginx)media_volume:/app/mediafiles(web) and/mediafiles(nginx)
- Nginx serves:
/static/β/staticfiles//media/β/mediafiles/
8.3 Changing environment variables
Edit .env and then recreate containers:
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up --build -d9. Code style & structure
- Django best practices:
settings/base.py,settings/dev.py,settings/prod.py- Class-based views (
ListView,DetailView,TemplateView,UpdateView) - URL namespacing (
blog:namespace) - Admin configured for Article/Comment moderation
- Comments & docstrings added in key places.
- Clear separation between dev and prod stacks (runserver vs Gunicorn).
10. How it all fits together
- Django app (
webservice):- Handles business logic (articles, categories, tags, comments, profiles)
- Uses PostgreSQL as database (
dbservice) - In dev: uses
runserveron port 8000 - In prod: uses Gunicorn on port 8000
- PostgreSQL (
dbservice):- Stores all data (users, posts, comments...)
- Data persisted in
postgres_dataDocker volume
- Static & media:
- Django collects static into
/app/staticfiles - Media uploads stored in
/app/mediafiles - Both paths are Docker volumes shared with Nginx
- Django collects static into
- Nginx (
nginxservice, prod only):- Listens on port 80
- Serves
/static/and/media/directly from volumes - Proxies all other requests to
web: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:
- Add registration templates using Djangoβs default auth views (signup page), or
- Add CKEditor/WYSIWYG for the
bodyfield, or - Add API endpoints (DRF) for future mobile / SPA usage.
Just tell me in the comments below.
π¬ Comments
No comments yet. Be the first to comment!
Login to comment.