CI/CD — Django + Next.js on Local VPS
Push to
main→ tests run → auto-deploy to local server. Uses self-hosted GitHub Actions runner (no public IP needed).
Architecture
git push origin main
↓
GitHub Actions (self-hosted runner on your server)
↓
├── test-backend (Django tests in Docker)
├── test-frontend (Next.js lint + tests)
↓ (both pass)
deploy.sh on server
├── git pull
├── docker compose build backend frontend
├── docker compose up -d --no-deps backend frontend
├── manage.py migrate
└── collectstatic
Self-hosted runner setup (one-time)
On your local server:
# GitHub: repo → Settings → Actions → Runners → New self-hosted runner
# Follow the exact commands GitHub gives. Then install as service:
sudo ./svc.sh install && sudo ./svc.sh startGitHub Actions workflow
.github/workflows/deploy.yml:
name: Test & Deploy
on:
push:
branches: [main]
jobs:
test-backend:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: |
docker compose run --rm \
-e DATABASE_URL=sqlite:///test.db \
-e SECRET_KEY=test-secret \
backend python manage.py test
test-frontend:
runs-on: self-hosted
needs: test-backend
steps:
- uses: actions/checkout@v4
- run: docker compose run --rm frontend npm run lint
- run: docker compose run --rm frontend npm test -- --passWithNoTests
deploy:
runs-on: self-hosted
needs: [test-backend, test-frontend]
steps:
- run: bash /home/deploy/app/deploy.shdeploy.sh
#!/bin/bash
set -e
cd /home/deploy/app
git pull origin main
export $(cat .env | xargs)
# Rebuild only app containers — never touch db
docker compose build backend frontend
docker compose up -d --no-deps --wait backend frontend
# Post-deploy steps
docker compose exec -T backend python manage.py migrate
docker compose exec -T backend python manage.py collectstatic --noinput
docker image prune -f
echo "✅ Deploy done"docker-compose.yml (key parts)
services:
db:
image: postgres:15
restart: always
volumes:
- pgdata:/var/lib/postgresql/data # ← never loses data
backend:
build: ./backend
restart: always
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health/"]
interval: 10s
retries: 3
frontend:
build: ./frontend
restart: always
volumes:
pgdata: # persists across all deploysZero-downtime options
| Approach | Downtime | Complexity |
|---|---|---|
Basic up -d | 5–15s | Simple |
| Nginx buffer | ~1–2s | Low |
| Blue-green | Zero | Medium |
Nginx buffer (simplest improvement):
upstream backend { server backend:8000; }
location /api/ {
proxy_pass http://backend;
proxy_next_upstream error timeout;
proxy_cache_use_stale error timeout updating;
}Safe migration pattern
Never rename columns in one deploy. Use expand/contract:
- Deploy 1 — add
new_col(nullable). Both old+new code work. - Deploy 2 — backfill, switch code to use
new_col. - Deploy 3 — drop
old_col.
Health check endpoint (Django)
# urls.py
from django.http import JsonResponse
path('api/health/', lambda r: JsonResponse({'status': 'ok'})),Updates — 2026-05-12
Dev vs Prod Docker Compose
Two separate files — dev mounts code as a volume; prod bakes code into the image.
my-app/
├── docker-compose.yml ← prod (on server)
├── docker-compose.dev.yml ← dev (on laptop)
├── backend/
│ ├── Dockerfile ← prod: COPY . .
│ └── Dockerfile.dev ← dev: no COPY, volume mount only
└── frontend/
├── Dockerfile ← prod: builds Next.js
└── Dockerfile.dev ← dev: npm run dev + hot reload
Dev compose (key parts):
# docker-compose.dev.yml
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
volumes:
- ./backend:/app # ← YOUR laptop code live in container
command: python manage.py runserver 0.0.0.0:8000
environment:
DEBUG: "True"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
volumes:
- ./frontend:/app # ← YOUR laptop code live in container
- /app/node_modules # keep node_modules inside container
command: npm run dev
environment:
WATCHPACK_POLLING: "true" # needed for hot reload in DockerHow the volume mount works:
Your Laptop Container
~/my-app/backend/ ←sync→ /app/
views.py views.py # same file, not a copy
models.py models.py # edit on laptop, container sees it instantly
Daily dev workflow:
docker compose -f docker-compose.dev.yml up # start everything
code . # edit files normally in VS Code
# Django auto-reloads on .py save ✅
# Next.js hot-reloads on .tsx save ✅Claude Code manages Docker from host
Claude Code runs on the host, edits files on the host, and issues Docker commands via exec.
No need to enter the container to write code.
# Claude Code can run these on your behalf:
docker compose exec backend python manage.py makemigrations
docker compose exec backend python manage.py migrate
docker compose exec backend python manage.py createsuperuser
docker compose logs -f backendRecommended CLAUDE.md for the project:
## Dev Environment
Start: docker compose -f docker-compose.dev.yml up -d
Stop: docker compose -f docker-compose.dev.yml down
## Common Commands
Migrations: docker compose exec backend python manage.py makemigrations
Migrate: docker compose exec backend python manage.py migrate
Django shell: docker compose exec backend python manage.py shell
Backend logs: docker compose logs -f backend
Frontend logs: docker compose logs -f frontend
Run tests: docker compose exec backend python manage.py test
## Git Flow
- Feature branches → PR → merge to main → auto deploy
- Never push directly to mainUpdates — 2026-05-13
Zero-downtime options
| Approach | Downtime | Complexity |
|---|---|---|
Basic up -d | 5–15 sec | Simple |
Nginx buffer (proxy_next_upstream) | ~1–2 sec | Low |
| Blue-green (two named instances) | Zero | Medium |
Rolling replicas (start-first) | Zero | Medium |
Blue-green deploy script (key steps):
# Detect active slot (blue or green), build the other, wait for healthcheck
docker compose build backend_$NEW
docker compose up -d backend_$NEW
# Wait until healthy, then switch nginx and stop old
sed -i "s/backend_$OLD/backend_$NEW/g" nginx.conf
docker compose exec nginx nginx -s reload
docker compose stop backend_$OLDClaude Code can manage all of this from the host
Claude Code runs on your laptop and can execute all docker compose commands, edit files, run migrations, and push to git — without entering any container. Give it a CLAUDE.md at the project root:
## Dev Environment
Start: docker compose -f docker-compose.dev.yml up -d
Migrate: docker compose exec backend python manage.py migrate
## Git Flow
Feature branches → PR → merge to main → auto deploy via self-hosted runner→ See also reference/docker-dev-environment for full dev vs prod Docker setup.