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 start

GitHub 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.sh

deploy.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 deploys

Zero-downtime options

ApproachDowntimeComplexity
Basic up -d5–15sSimple
Nginx buffer~1–2sLow
Blue-greenZeroMedium

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:

  1. Deploy 1 — add new_col (nullable). Both old+new code work.
  2. Deploy 2 — backfill, switch code to use new_col.
  3. 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 Docker

How 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 backend

Recommended 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 main

Updates — 2026-05-13

Zero-downtime options

ApproachDowntimeComplexity
Basic up -d5–15 secSimple
Nginx buffer (proxy_next_upstream)~1–2 secLow
Blue-green (two named instances)ZeroMedium
Rolling replicas (start-first)ZeroMedium

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_$OLD

Claude 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.