The Full-Stack Deployment Checklist: Django, DRF, Next.js, Docker, VPS

Deploying a full-stack app to a VPS for the first time surfaces a thousand small decisions most tutorials gloss over. Which environment variables must be set? Why does the healthcheck fail when the container is clearly running? Why does pnpm build pass locally but fail on the deployment server?

This is the checklist I wish I had when I shipped the System Design Simulator — a Django + DRF backend, a Next.js frontend, orchestrated by Docker Compose, and deployed to a VPS via Coolify. It's organized like an airline preflight: every step is a checkbox, every command is copy-paste ready, and every file edit references a specific section — not a full file dump.

The stack in scope:

  • Backend: Django 5.2 + Django REST Framework + Celery + Redis + PostgreSQL
  • Frontend: Next.js 16 + Tailwind + TypeScript
  • Containerization: Docker Compose (separate dev and prod files)
  • Deployment target: any Linux VPS running Coolify (a self-hosted PaaS — think Heroku, but you own the server)

If you're bootstrapping a new project with this stack, follow this top to bottom. If you're debugging a deployment, jump straight to Part 7 → Common deploy failures + fixes and the Key Lessons Learned at the end.



Part 1 — Backend Bootstrap (Django + DRF)

1.1 Python environment

# Check Python version (need 3.13+)
python3 --version
brew list | grep python

# Create virtual environment
python3.13 -m venv .venv
source .venv/bin/activate

# Verify
python --version  # should match 3.13+

1.2 Install core dependencies

python3 -m pip install django~=5.2.0
python3 -m pip install djangorestframework~=3.16.0
python3 -m pip install python-decouple
python3 -m pip install dj-database-url
python3 -m pip install "psycopg[binary]"

# Freeze deps
python3 -m pip freeze > requirements.txt

1.3 Scaffold Django project

# Start project in current directory (note the trailing dot)
django-admin startproject <project_name> .

# Sanity check
python3 manage.py runserver

1.4 Edit <project_name>/settings.py

  • Imports — top of file:

    import dj_database_url
    from decouple import config, Csv
    from datetime import timedelta
    from pathlib import Path
    
  • SECRET_KEY + DEBUG — read from env:

    SECRET_KEY = config("SECRET_KEY", default="django-insecure-<keep-original-as-fallback>")
    DEBUG = config("DEBUG", default=True, cast=bool)
    ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost, 127.0.0.1", cast=Csv())
    
  • INSTALLED_APPS — organize into logical groups:

    INSTALLED_APPS = [
        # Django core
        "django.contrib.admin", ...,
    
        # 3rd party core
        "rest_framework",
    
        # Project apps (add as you create them)
    ]
    
  • DATABASES — use dj_database_url:

    DATABASES = {
        "default": dj_database_url.parse(
            config("DATABASE_URL", default=f"sqlite:///{BASE_DIR}/db.sqlite3")
        )
    }
    
  • STATIC section:

    STATIC_URL = "static/"
    STATIC_ROOT = BASE_DIR / "staticfiles"
    

1.5 Custom User model (must do BEFORE first migration)

python3 manage.py startapp accounts
  • Edit accounts/models.py — define CustomUser(AbstractUser)
  • Edit accounts/forms.pyCustomUserCreationForm + CustomUserChangeForm
  • Edit accounts/admin.py — register with custom forms
  • Add "accounts" to INSTALLED_APPS
  • Add AUTH_USER_MODEL = "accounts.CustomUser" to settings.py
# Verify no migrations have run yet
python3 manage.py showmigrations

# Create + apply initial migrations
python3 manage.py makemigrations accounts
python3 manage.py migrate
python3 manage.py showmigrations


Part 2 — Backend Features (Auth, Caching, Async)

2.1 CORS + Auth stack

python3 -m pip install django-cors-headers~=4.9.0
python3 -m pip install djangorestframework-simplejwt
python3 -m pip install dj-rest-auth
python3 -m pip install django-allauth
python3 -m pip freeze > requirements.txt
  • settings.py — add to INSTALLED_APPS: corsheaders, rest_framework_simplejwt, rest_framework_simplejwt.token_blacklist, django.contrib.sites, allauth, allauth.account, allauth.socialaccount, dj_rest_auth, dj_rest_auth.registration
  • settings.py — add "corsheaders.middleware.CorsMiddleware" at top of MIDDLEWARE
  • settings.py — add "allauth.account.middleware.AccountMiddleware" to MIDDLEWARE
  • settings.py — configure REST_FRAMEWORK, SIMPLE_JWT, REST_AUTH, SITE_ID = 1, ACCOUNT_* settings
  • settings.pyCORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=DEBUG, cast=bool) and conditional CORS_ALLOWED_ORIGINS
  • settings.pyCSRF_TRUSTED_ORIGINS from env
  • urls.py — wire up api/token/, api/auth/, api/auth/registration/

2.2 Dev tooling

python3 -m pip install black
python3 -m pip install django-silk
python3 -m pip install django-extensions
python3 -m pip install drf-spectacular
python3 -m pip freeze > requirements.txt
  • settings.py — add silk, drf_spectacular, django_extensions to INSTALLED_APPS
    • CRITICAL: gate silk behind if DEBUG: — never run Silk in production
  • settings.py — add "silk.middleware.SilkyMiddleware" to MIDDLEWARE (also gated by DEBUG)
  • settings.pySPECTACULAR_SETTINGS block
  • urls.py — wire up api/schema/, api/schema/swagger-ui/, api/schema/redoc/, and silk/ (gated by DEBUG)
# Generate OpenAPI schema
docker compose exec backend python3 manage.py spectacular --color --file schema.yml

2.3 Other / Extra dependencies

python3 -m pip install requests        # HTTP client for calling external APIs
python3 -m pip install django-filter   # Filter backend for DRF list views
python3 -m pip freeze > requirements.txt
  • settings.py — add "django_filters" to INSTALLED_APPS
  • settings.py — add "django_filters.rest_framework.DjangoFilterBackend" to REST_FRAMEWORK["DEFAULT_FILTER_BACKENDS"]

2.4 Testing

python3 -m pip install pytest pytest-django
python3 -m pip freeze > requirements.txt
  • Create pytest.ini (or pyproject.toml [tool.pytest.ini_options]) with:
    [pytest]
    DJANGO_SETTINGS_MODULE = <project>.settings
    python_files = tests.py test_*.py *_tests.py
    
  • Run tests: docker compose exec backend pytest (or pytest -v locally with venv active)

2.5 Redis + Celery

python3 -m pip install "redis[hiredis]"
python3 -m pip install django-redis
python3 -m pip install -U Celery
python3 -m pip install "celery[redis]"
python3 -m pip install flower
python3 -m pip install django-celery-beat
python3 -m pip freeze > requirements.txt
  • settings.pyCACHES block pointing to REDIS_URL
  • settings.pyCELERY_BROKER_URL, CELERY_RESULT_BACKEND, CELERY_ACCEPT_CONTENT, etc.
  • settings.py — add django_celery_beat to INSTALLED_APPS
  • Create <project>/celery.py with Celery app instance
  • Edit <project>/__init__.py to import the celery app


Part 3 — Containerization (Dev)

3.1 Backend Dockerfile

Add Dockerfile in backend project root. Key sections:

  • Base: FROM python:3.13-slim
  • ENV PYTHONDONTWRITEBYTECODE=1, PYTHONUNBUFFERED=1, PYTHONPATH=/app
  • WORKDIR /app
  • Install system deps: postgresql-client, build-essential, libpq-dev, curl, netcat-traditional, git, procps
  • COPY requirements.txt /app/ then pip install --no-cache-dir -r requirements.txt
  • COPY . /app/
  • Create non-root user: adduser --disabled-password --gecos '' appuserUSER appuser
  • EXPOSE 8000
  • HEALTHCHECK hitting /admin/login/ (or /api/health/ if you have one)
  • CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] (dev default; prod is overridden by compose)

3.2 Health endpoint (recommended)

Add <project>/views.py:

from django.db import connection
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

@api_view(["GET"])
@permission_classes([AllowAny])
def health_check(request):
    health = {"status": "ok", "services": {}}
    try:
        connection.ensure_connection()
        health["services"]["database"] = "ok"
    except Exception as e:
        health["status"] = "degraded"
        health["services"]["database"] = str(e)
    try:
        from django_redis import get_redis_connection
        get_redis_connection("default").ping()
        health["services"]["redis"] = "ok"
    except Exception as e:
        health["status"] = "degraded"
        health["services"]["redis"] = str(e)
    return Response(health, status=200 if health["status"] == "ok" else 503)
  • Wire up in urls.py: path("api/health/", health_check)

3.3 docker-compose.dev.yml

Services needed:

  • postgres (image postgres:17, env vars, pg_isready healthcheck, named volume)
  • redis (image redis:7-alpine, --appendonly yes, redis-cli ping healthcheck)
  • backend (build, volume mount .:/app for hot reload, runserver command, depends_on postgres+redis healthy)
  • celery_worker (same image, command: celery -A <project> worker --loglevel=info)
  • celery_beat (command: celery -A <project> beat --scheduler django_celery_beat.schedulers:DatabaseScheduler)
  • flower (command: celery -A <project> flower --port=5555, no auth in dev)
  • frontend (build target development, volume mount source for hot reload)

3.4 Run dev stack

# First-time build
docker compose -f docker-compose.dev.yml up --build

# Daily startup
docker compose -f docker-compose.dev.yml up

# Force rebuild after dep changes
docker compose -f docker-compose.dev.yml build --no-cache && docker compose -f docker-compose.dev.yml up

# Logs
docker compose logs -f backend
docker compose logs --tail 50 backend

# Shell into backend
docker compose exec backend bash

# Django management commands
docker compose exec backend python3 manage.py migrate
docker compose exec backend python3 manage.py makemigrations
docker compose exec backend python3 manage.py createsuperuser
docker compose exec backend python3 manage.py showmigrations

3.5 Docker troubleshooting

# Nuke volumes (fixes stale data, anonymous volumes)
docker compose down -v
docker volume ls
docker volume rm <project>_postgres_data

# Inspect running containers
docker ps
docker compose ps

# Stop everything
docker compose down

Gotcha: anonymous volumes (like node_modules) persist across rebuilds. When you add a frontend dependency, run docker compose down -v or docker compose up --renew-anon-volumes — otherwise the new dep won't be installed.



Part 4 — Frontend Bootstrap (Next.js)

Package manager note: This guide prefers pnpm but offers npm equivalents alongside. pnpm also supports a shorthand that skips the run keyword — so pnpm build is equivalent to pnpm run build (same for dev, start, test, etc.). npm is stricter: only npm start and npm test skip run; everything else requires the explicit npm run <script> form. For consistency and copy-paste safety across both managers, the commands below use the explicit run form.

pnpm ↔ npm cheat sheet

What it doespnpmnpm
Install a package manager globallynpm install -g pnpm(already installed with Node.js)
Install all deps from lockfilepnpm installnpm install
Install a new dependencypnpm add <pkg> (or pnpm install <pkg>)npm install <pkg>
Install a dev-only dependencypnpm add -D <pkg>npm install -D <pkg>
Install production deps onlypnpm install --prodnpm install --omit=dev
Remove a dependencypnpm remove <pkg>npm uninstall <pkg>
Frozen-lockfile install (CI / Docker)pnpm install --frozen-lockfilenpm ci
Update deps to latest allowed by semverpnpm updatenpm update
Run dev serverpnpm run dev (or pnpm dev)npm run dev
Production buildpnpm run build (or pnpm build)npm run build
Run built apppnpm run start (or pnpm start)npm start (alias) / npm run start
Run testspnpm run test (or pnpm test)npm test (alias) / npm run test
Run an arbitrary scriptpnpm run <script>npm run <script>
Execute a binary without installingpnpm dlx <binary>npx <binary>
Lockfile producedpnpm-lock.yamlpackage-lock.json

4.1 Scaffold

Option A — pnpm from the start (preferred, assumes pnpm is installed globally):

# Install pnpm once, globally
npm install -g pnpm

# Scaffold directly with pnpm (skips the npm → pnpm migration below)
npx create-next-app@latest <frontend_dir_name> --use-pnpm

Option B — default (npm), then switch to pnpm:

npx create-next-app@latest <frontend_dir_name>

Answer the prompts: TypeScript, Tailwind, App Router, etc.

  • Delete the nested .gitignore generated by create-next-app (if using a monorepo layout)
  • Move/copy Dockerfile into frontend dir (see Part 5)

4.2 Switch to pnpm (only if you used Option B)

npm install -g pnpm
cd <frontend_dir>
rm -rf node_modules package-lock.json
pnpm install

4.3 Add dependencies

# pnpm (preferred)
pnpm install recharts

# npm equivalent
npm install recharts

4.4 Verify build locally BEFORE pushing

# pnpm (preferred)
pnpm run build

# npm equivalent
npm run build

# framework-direct (works regardless of package manager)
npx next build 2>&1 | tail -40

Critical: next dev (via pnpm run dev / npm run dev) skips TypeScript type-checking (Turbopack). The production build (pnpm run build / npm run build) enforces it. Always run the production build locally before pushing to catch type errors that would fail on the deployment server.



Part 5 — Frontend Dockerfile (Multi-stage)

A Next.js Dockerfile with 5 stages: basedepsdevelopmentbuilderproduction.

Key points:

  • base: FROM node:20-alpine AS base
  • deps: install libc6-compat, copy all lockfile types, auto-detect package manager (yarn/npm/pnpm)
  • development: copy deps + source, create nextjs user, CMD uses detected package manager
  • builder: set ENV NODE_ENV=production and NEXT_TELEMETRY_DISABLED=1, run pnpm run build (or npm run build)
  • production: copy .next, public, package.json, lockfile from builder; install --prod deps only; non-root user; CMD pnpm run start (or npm run start)


Part 6 — Production Preparation

6.1 Prod dependencies

python3 -m pip install gunicorn
python3 -m pip install whitenoise
python3 -m pip freeze > requirements.txt

6.2 settings.py prod hardening

  • Add "whitenoise.middleware.WhiteNoiseMiddleware" after SecurityMiddleware in MIDDLEWARE
  • STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
  • Gate silk app, middleware, and URL behind if DEBUG:
  • Gate CORS_ALLOWED_ORIGINS = config(...) behind if not CORS_ALLOW_ALL_ORIGINS:
  • Ensure DEBUG = config("DEBUG", default=True, cast=bool) reads from env (set to 0 in prod)
  • CSRF_COOKIE_SECURE = True and SESSION_COOKIE_SECURE = True when not DEBUG
  • ACCESS_TOKEN_LIFETIME / REFRESH_TOKEN_LIFETIME in SIMPLE_JWT

6.3 Split compose files

  • docker-compose.ymlproduction (no source mounts, gunicorn, restart: unless-stopped)
  • docker-compose.dev.yml — development (source mounts, runserver)

6.4 Production backend service in docker-compose.yml

backend:
  build:
    context: ./<backend_dir>
    dockerfile: Dockerfile
  environment:
    - DEBUG=0
    - SECRET_KEY=${SECRET_KEY}
    - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    - REDIS_URL=redis://redis:6379/0
    - ALLOWED_HOSTS=localhost,127.0.0.1,${ALLOWED_HOSTS}  # CRITICAL: prepend localhost for healthchecks
    - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
  command: >
    sh -c "python manage.py migrate &&
           python manage.py collectstatic --noinput &&
           gunicorn <project>.wsgi:application
           --bind 0.0.0.0:8000 --workers 3 --timeout 120"
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:8000/admin/login/"]
    interval: 30s
    timeout: 10s
    start_period: 60s  # give time for migrate + collectstatic
    retries: 3
  restart: unless-stopped

6.5 Frontend service in docker-compose.yml

frontend:
  build:
    context: ./<frontend_dir>
    dockerfile: Dockerfile
    target: production  # CRITICAL: targets production stage
  environment:
    - NODE_ENV=production
    - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
  restart: unless-stopped


Part 7 — VPS Deployment (via Coolify)

Coolify is a self-hosted PaaS — you install it on your VPS once, and from then on it handles deployments via a web UI. Push to GitHub, Coolify pulls, builds, and runs. Think "self-hosted Heroku".

7.1 Repo prep

  • Push code to GitHub
  • Confirm docker-compose.yml is at repo root
  • Confirm build passes locally: pnpm run build / npm run build (frontend) and docker compose build (full stack)

7.2 Coolify project setup

  • Create a new "Docker Compose" resource pointing to your GitHub repo
  • Set compose file path to docker-compose.yml
  • Configure domains per service:
    • Frontend: yourdomain.com
    • Backend: api.yourdomain.com

7.3 Environment variables (MUST SET)

VarExampleNotes
SECRET_KEYGenerate: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"Django crashes if empty
ALLOWED_HOSTSapi.yourdomain.comlocalhost,127.0.0.1, are prepended in compose
CORS_ALLOWED_ORIGINShttps://yourdomain.comFrontend origin(s)
CSRF_TRUSTED_ORIGINShttps://yourdomain.com,https://api.yourdomain.com
POSTGRES_USERmyapp_user
POSTGRES_PASSWORDstrong random string
POSTGRES_DBmyapp_db
FLOWER_BASIC_AUTHadmin:<password>Otherwise Flower fails with a blank-string warning
NEXT_PUBLIC_API_URLhttps://api.yourdomain.comFrontend build-time var
  • Uncheck "Available at Buildtime" for NODE_ENV if Coolify sets it there — forcing NODE_ENV=development at build time causes pnpm/npm to skip the devDependencies Next.js needs to compile

7.4 Common deploy failures + fixes

SymptomFix
Backend crashes in ~2s with "unhealthy"Missing env var (usually SECRET_KEY or POSTGRES_*). Empty env vars override settings.py defaults because config() sees the var exists
Backend healthcheck fails but container runsALLOWED_HOSTS doesn't include localhost — healthcheck from inside the container hits localhost:8000 and Django 400s
Frontend TypeScript errors on deploy but not locallyRun pnpm run build / npm run build locally before pushing. next dev skips type-checking
Coolify build says "skipped devDependencies"Uncheck "Available at Buildtime" for NODE_ENV in Coolify env var settings
Docker compose exits 1, blocks entire deployEven if only backend fails, docker compose up -d returns non-zero. Either fix the failing service or temporarily comment it out of compose
Flower warning about FLOWER_BASIC_AUTHSet it in Coolify env vars (format: user:password)

7.5 First deploy verification

  • Deployment logs show all containers StartedHealthy
  • Frontend loads at yourdomain.com
  • Backend admin at api.yourdomain.com/admin/login/
  • Health endpoint: curl https://api.yourdomain.com/api/health/
  • CORS check: frontend can fetch() from backend without CORS errors in the browser console


Part 8 — Local Dev Commands Reference

# Backend (via Docker Compose)
docker compose -f docker-compose.dev.yml up
docker compose -f docker-compose.dev.yml up --build
docker compose -f docker-compose.dev.yml build --no-cache && docker compose -f docker-compose.dev.yml up
docker compose -f docker-compose.dev.yml down
docker compose -f docker-compose.dev.yml down -v

docker compose exec backend python3 manage.py migrate
docker compose exec backend python3 manage.py makemigrations
docker compose exec backend python3 manage.py createsuperuser
docker compose exec backend python3 manage.py shell_plus
docker compose exec backend python3 manage.py spectacular --color --file schema.yml

docker compose logs -f backend
docker compose logs --tail 50 backend

# Frontend (local, no Docker)
cd <frontend_dir>

# pnpm (preferred) — the `run` keyword is optional for pnpm
pnpm (run) dev
pnpm (run) build
pnpm (run) start
pnpm install <package>

# npm equivalents — `run` is required (except for `start` and `test`)
npm run dev
npm run build
npm run start
npm install <package>

# Python virtual environment
source .venv/bin/activate
deactivate
python3 -m pip freeze > requirements.txt


Key Lessons Learned

These are the non-obvious failures I hit during my first deploy. If you're debugging, scan this list first.

1. config() with empty env vars overrides defaults. If SECRET_KEY= is set (empty) in the container, Django uses the empty string, not the default from settings.py. python-decouple sees the variable exists and uses it, even if the value is blank. Always check that all required env vars are actually set in your deployment platform — not just declared.

2. Container healthchecks run from inside the container. The URL inside is localhost:8000, so localhost must be in ALLOWED_HOSTS regardless of your public domain. I was setting ALLOWED_HOSTS=api.example.com and watching Django 400 every healthcheck until I prepended localhost,127.0.0.1, in the compose env.

3. Dev tools don't belong in production. Silk, debug toolbar, Spectacular swagger-ui — gate them behind if DEBUG:. They add overhead, they bloat your image, and they expose attack surface. The prod build should be lean.

4. next dev and next build check different things. Turbopack dev mode (via pnpm run dev / npm run dev) skips type-checking for speed. The production build enforces it. Always run pnpm run build / npm run build locally before pushing — it's the only way to catch TS errors that will fail on the deployment server.

5. Docker compose fails fast. If one service is unhealthy, docker compose up -d exits non-zero and the entire deployment is marked failed — even healthy services get torn down. Either fix all services or remove the broken ones from compose temporarily. Don't assume a dependency-free service will survive a sibling's failure.

6. Anonymous Docker volumes are sticky. node_modules in a named or anonymous volume persists across rebuilds. When you add a frontend dependency, the new package won't appear in the container until you run docker compose down -v or docker compose up --renew-anon-volumes.

7. Multi-stage Dockerfiles need the right target. In docker-compose.yml, explicitly set target: production (or target: development in the dev file). Skip it and Docker may pick the last-declared stage, or error out entirely.

8. NODE_ENV=development at build time breaks Next.js. Counterintuitively, you want NODE_ENV=production scoped only to the builder stage of your Dockerfile. If it's set globally (e.g., by Coolify marking it as "available at buildtime"), then pnpm install / npm install skips devDependencies that Next.js needs to actually compile the build — TypeScript, Tailwind, etc. The result is cryptic errors like "tsc not found" or "Cannot find module 'tailwindcss'".



Closing Thought

The fact that deploying a "hello world" full-stack app requires this many checkpoints is not a bug in the tooling — it's the tax on flexibility. Each choice in this stack (Django, DRF, Celery, Redis, Postgres, Next.js, Docker, Coolify) is deliberate, and each one exposes its own set of configuration surfaces.

The good news: once you've been through it end-to-end, the next project is a matter of running down this list. Fork a template, swap names, fill in env vars, push.

Bookmark this page. Or better yet — fork the checklist, tailor it to your stack, and publish your own. Preflight checklists are how airlines stopped losing planes to forgotten switches. They work just as well for forgotten environment variables.

The Full-Stack Deployment Checklist: Django, DRF, Next.js, Docker, VPS - Bogdan Andrei