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— defineCustomUser(AbstractUser) - Edit
accounts/forms.py—CustomUserCreationForm+CustomUserChangeForm - Edit
accounts/admin.py— register with custom forms - Add
"accounts"toINSTALLED_APPS - Add
AUTH_USER_MODEL = "accounts.CustomUser"tosettings.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 toINSTALLED_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 ofMIDDLEWARE -
settings.py— add"allauth.account.middleware.AccountMiddleware"toMIDDLEWARE -
settings.py— configureREST_FRAMEWORK,SIMPLE_JWT,REST_AUTH,SITE_ID = 1,ACCOUNT_*settings -
settings.py—CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=DEBUG, cast=bool)and conditionalCORS_ALLOWED_ORIGINS -
settings.py—CSRF_TRUSTED_ORIGINSfrom env -
urls.py— wire upapi/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— addsilk,drf_spectacular,django_extensionstoINSTALLED_APPS- CRITICAL: gate
silkbehindif DEBUG:— never run Silk in production
- CRITICAL: gate
-
settings.py— add"silk.middleware.SilkyMiddleware"toMIDDLEWARE(also gated by DEBUG) -
settings.py—SPECTACULAR_SETTINGSblock -
urls.py— wire upapi/schema/,api/schema/swagger-ui/,api/schema/redoc/, andsilk/(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"toINSTALLED_APPS -
settings.py— add"django_filters.rest_framework.DjangoFilterBackend"toREST_FRAMEWORK["DEFAULT_FILTER_BACKENDS"]
2.4 Testing
python3 -m pip install pytest pytest-django
python3 -m pip freeze > requirements.txt
- Create
pytest.ini(orpyproject.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(orpytest -vlocally 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.py—CACHESblock pointing toREDIS_URL -
settings.py—CELERY_BROKER_URL,CELERY_RESULT_BACKEND,CELERY_ACCEPT_CONTENT, etc. -
settings.py— adddjango_celery_beattoINSTALLED_APPS - Create
<project>/celery.pywithCeleryapp instance - Edit
<project>/__init__.pyto 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/thenpip install --no-cache-dir -r requirements.txt -
COPY . /app/ - Create non-root user:
adduser --disabled-password --gecos '' appuser→USER appuser -
EXPOSE 8000 -
HEALTHCHECKhitting/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_isreadyhealthcheck, named volume) - redis (image
redis:7-alpine,--appendonly yes,redis-cli pinghealthcheck) - backend (build, volume mount
.:/appfor hot reload,runservercommand,depends_onpostgres+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.
pnpmalso supports a shorthand that skips therunkeyword — sopnpm buildis equivalent topnpm run build(same fordev,start,test, etc.).npmis stricter: onlynpm startandnpm testskiprun; everything else requires the explicitnpm run <script>form. For consistency and copy-paste safety across both managers, the commands below use the explicitrunform.
pnpm ↔ npm cheat sheet
| What it does | pnpm | npm |
|---|---|---|
| Install a package manager globally | npm install -g pnpm | (already installed with Node.js) |
| Install all deps from lockfile | pnpm install | npm install |
| Install a new dependency | pnpm add <pkg> (or pnpm install <pkg>) | npm install <pkg> |
| Install a dev-only dependency | pnpm add -D <pkg> | npm install -D <pkg> |
| Install production deps only | pnpm install --prod | npm install --omit=dev |
| Remove a dependency | pnpm remove <pkg> | npm uninstall <pkg> |
| Frozen-lockfile install (CI / Docker) | pnpm install --frozen-lockfile | npm ci |
| Update deps to latest allowed by semver | pnpm update | npm update |
| Run dev server | pnpm run dev (or pnpm dev) | npm run dev |
| Production build | pnpm run build (or pnpm build) | npm run build |
| Run built app | pnpm run start (or pnpm start) | npm start (alias) / npm run start |
| Run tests | pnpm run test (or pnpm test) | npm test (alias) / npm run test |
| Run an arbitrary script | pnpm run <script> | npm run <script> |
| Execute a binary without installing | pnpm dlx <binary> | npx <binary> |
| Lockfile produced | pnpm-lock.yaml | package-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
.gitignoregenerated bycreate-next-app(if using a monorepo layout) - Move/copy
Dockerfileinto 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: base → deps → development → builder → production.
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
nextjsuser,CMDuses detected package manager - builder: set
ENV NODE_ENV=productionandNEXT_TELEMETRY_DISABLED=1, runpnpm run build(ornpm run build) - production: copy
.next,public,package.json, lockfile from builder; install--proddeps only; non-root user;CMD pnpm run start(ornpm 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"afterSecurityMiddlewareinMIDDLEWARE -
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" - Gate
silkapp, middleware, and URL behindif DEBUG: - Gate
CORS_ALLOWED_ORIGINS = config(...)behindif not CORS_ALLOW_ALL_ORIGINS: - Ensure
DEBUG = config("DEBUG", default=True, cast=bool)reads from env (set to0in prod) -
CSRF_COOKIE_SECURE = TrueandSESSION_COOKIE_SECURE = Truewhennot DEBUG -
ACCESS_TOKEN_LIFETIME/REFRESH_TOKEN_LIFETIMEinSIMPLE_JWT
6.3 Split compose files
-
docker-compose.yml— production (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.ymlis at repo root - Confirm build passes locally:
pnpm run build/npm run build(frontend) anddocker 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
- Frontend:
7.3 Environment variables (MUST SET)
| Var | Example | Notes |
|---|---|---|
SECRET_KEY | Generate: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" | Django crashes if empty |
ALLOWED_HOSTS | api.yourdomain.com | localhost,127.0.0.1, are prepended in compose |
CORS_ALLOWED_ORIGINS | https://yourdomain.com | Frontend origin(s) |
CSRF_TRUSTED_ORIGINS | https://yourdomain.com,https://api.yourdomain.com | |
POSTGRES_USER | myapp_user | |
POSTGRES_PASSWORD | strong random string | |
POSTGRES_DB | myapp_db | |
FLOWER_BASIC_AUTH | admin:<password> | Otherwise Flower fails with a blank-string warning |
NEXT_PUBLIC_API_URL | https://api.yourdomain.com | Frontend build-time var |
- Uncheck "Available at Buildtime" for
NODE_ENVif Coolify sets it there — forcingNODE_ENV=developmentat build time causes pnpm/npm to skip the devDependencies Next.js needs to compile
7.4 Common deploy failures + fixes
| Symptom | Fix |
|---|---|
| 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 runs | ALLOWED_HOSTS doesn't include localhost — healthcheck from inside the container hits localhost:8000 and Django 400s |
| Frontend TypeScript errors on deploy but not locally | Run 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 deploy | Even 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_AUTH | Set it in Coolify env vars (format: user:password) |
7.5 First deploy verification
- Deployment logs show all containers
Started→Healthy - 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.