By the end of this, every push to main builds a fresh Docker image of your Django app, pushes it to GitHub's container registry, and restarts it on your server with migrations applied. No more SSH-ing in to git pull by hand.

What you need

  • A Django project with a requirements.txt that includes gunicorn.
  • A Linux server (any VPS) with Docker and the Docker Compose plugin installed.
  • A GitHub repository for the project.
  1. The Dockerfile

Put this in the project root. Change config.wsgi to your project's WSGI module.

dockerfile

FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . # Collect static files into the image (needs STATIC_ROOT set in settings) RUN python manage.py collectstatic --noinput EXPOSE 8000 CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] 

Add a .dockerignore so you don't copy junk into the image:

text

.git .env __pycache__/ *.pyc venv/ node_modules/ media/ 
  1. The compose file on the server

On your server, create a folder such as /srv/myapp with this docker-compose.yml and a .env file holding your secrets. Replace the image name with your own, in lowercase.

yaml

services: web: image: ghcr.io/yourname/myapp:latest env_file: .env ports: - "8000:8000" depends_on: - db restart: unless-stopped db: image: postgres:16 environment: POSTGRES_DB: myapp POSTGRES_USER: myapp POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data restart: unless-stopped volumes: pgdata: 

Put a reverse proxy such as Nginx or Caddy in front of port 8000 for HTTPS. Caddy is the quickest, because it gets certificates for you automatically.

  1. Repository secrets

In your GitHub repo, go to Settings, then Secrets and variables, then Actions, and add:

  • SERVER_HOST: your server's IP or domain.
  • SERVER_USER: the user that runs Docker on the server.
  • SERVER_SSH_KEY: a private SSH key whose public half is in that user's ~/.ssh/authorized_keys.
  1. The workflow

Save this as .github/workflows/deploy.yml.

{% raw %} yaml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/yourname/myapp:latest - uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | cd /srv/myapp docker compose pull web docker compose up -d web docker compose exec T web python manage.py migrate noinput {% endraw %} 

Image names on GitHub's registry must be lowercase. If your GitHub username has capitals, type the lowercase name into tags yourself rather than building it from github.repository.

  1. Let the server pull the image

If the package is private, log the server in to the registry once, using a GitHub personal access token with the read:packages scope:

bash
echo YOUR_TOKEN | docker login ghcr.io -u yourname --password-stdin
Push and watch it deploy

Commit everything and push to main. Open the Actions tab on GitHub and you'll see the build, the push and the deploy run in order. From now on, merging to **main **is your deploy button.

Common problems
collectstatic fails during the build: set STATIC_ROOT in settings, and make sure settings don't crash when secrets are missing at build time.
Permission denied on SSH: the public key isn't in authorized_keys for SERVER_USER, or the key secret has extra spaces.
Old version still running: check that the tags in the workflow and the image in compose are exactly the same name.

How do you handle deploys on your side projects? Still SSH and git pull, or something fancier? Tell me in the comments.

I write more of these at amanxavier.com, mostly about Django, Next.js and building products from India.