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:

  1. Project structure
  2. Django code (settings, models, views, urls, templates)
  3. Docker & docker-compose (dev + prod)
  4. Nginx config
  5. .env
  6. README.md
  7. Start the stack
  8. Production deployment
  9. Code style & structure
  10. Quick explanation of how everything fits together

1. Project structure

1.1. Prereqs

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/activate

1.3. Install Django

pip install --upgrade pip
pip install django

1.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>&copy; {{ 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 %}"
          >&laquo; Prev</a>
        </li>
      {% else %}
        <li class="page-item disabled">
          <span class="page-link">&laquo; 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 &raquo;</a>
        </li>
      {% else %}
        <li class="page-item disabled">
          <span class="page-link">Next &raquo;</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;
    }
}

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:

7. Development (with Docker)

7.1 Start the stack

docker-compose up --build

This will:

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 migrate

7.3 Create superuser

docker-compose run --rm web python manage.py createsuperuser

Then 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 -d

This will:

Access the site at http://YOUR_SERVER_IP/ (or your domain).

8.2 Static & media files

Django is configured with:

In production:

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 -d

9. Code style & structure

10. How it all fits together

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:

Just tell me in the comments below.