How I Auto-Deploy Django to a Hetzner VM with GitHub Actions

November 1, 2026 · Ahmad M. Waddah · 2 reads

For a long time, deploying this website was the step I dreaded. Every release meant SSH-ing into the server, recalling the exact order of operations — pull, install, migrate, collect, restart — and hoping I had not skipped one. Forget the static files and the site shipped with stale CSS. Skip a migration and the first request to a new model crashed. The process lived in my head, and my head is not a runbook.

Today, deploying is git push origin main. A GitHub Actions workflow opens an SSH session to my Hetzner VM, pulls the code, installs production dependencies, applies migrations, collects static files, and restarts the app server. Nothing runs from my laptop, no manual step exists, and the deployment script is versioned in the same repository as the code it deploys.

This post walks through the real workflow file that runs on every push to main, line by line — plus the decisions around static files, secrets, and how I verify and roll back every deploy.

Rules first: only main deploys, and only after CI

The deploy workflow triggers on exactly one event:

on:
  push:
    branches:
      - main

Feature branches do not deploy — ever. They end their life in CI, where every push runs the quality gate:

- name: Lint (ruff check)
  run: ruff check .

- name: Format check (ruff format)
  run: ruff format --check .

- name: Django system check
  run: python manage.py check

- name: Migration dry-run check
  run: python manage.py makemigrations --check --dry-run

- name: Apply migrations to test DB
  run: python manage.py migrate --noinput

- name: Run tests
  run: pytest --tb=short -q

That gate runs against a real PostgreSQL 15 service container, not SQLite, so migration behavior matches production. One detail worth copying: ruff is pinned at 0.15.11 in the CI workflow (pip install "ruff==0.15.11"). A newer ruff release started formatting Markdown code blocks and turned my CI red with zero code changes — pinning the version kept CI and my local environment playing the same game.

The step order matters too. makemigrations --check --dry-run fails the build whenever a model change is missing its migration file, so an un-migrated model change can never reach main — and therefore can never reach the server. The production migrate step applies migrations that CI already verified against a real database. Discovering a missing migration mid-deploy is thus a bug I stopped being able to write.

The full workflow

This is .github/workflows/deploy.yml, the entire file, unedited:

name: Deploy to Hetzner

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            set -e
            mkdir -p ~/.ssh && chmod 700 ~/.ssh
            grep -q "github.com" ~/.ssh/known_hosts 2>/dev/null || echo "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" >> ~/.ssh/known_hosts
            cd /home/deploy/AMWShowCases
            git remote set-url origin git@github.com:AhmadMWaddah/AMW-ShowCases.git
            git pull origin main
            source venv/bin/activate
            pip install -r requirements/prod.txt -q
            set -a; source /home/deploy/.env; set +a
            python manage.py migrate --noinput
            python manage.py collectstatic --noinput
            sudo systemctl restart amwshowcase

It is a thin workflow on purpose — all the substance is in server-side decisions. Walking through them:

set -e fails fast. The script runs inside the SSH session's shell. Without set -e, a failing pip install would not stop migrate from running against old code with new data expectations — the worst kind of half-deploy. With it, the first failed command exits non-zero, the workflow marks the deploy failed, and the server keeps serving its previous, consistent state.

The pinned known_hosts entry. The script greps ~/.ssh/known_hosts for github.com and appends GitHub's SSH host key if it is missing. Skip this and the first git pull of a fresh session hangs on an interactive host key verification prompt in the middle of the deploy — a non-interactive shell cannot answer it, and the workflow times out while the site waits. Pinned host key, no prompt, ever.

A dedicated deploy user. The action connects as deploy, not root and not my personal account. That user owns /home/deploy/AMWShowCases and the application venv, and has exactly one sudo ability: restarting the amwshowcase systemd unit. If the SSH key leaks, the blast radius is one service on one VM — not an administrator shell on the machine.

The remote URL is pinned explicitly. git remote set-url origin git@github.com:AhmadMWaddah/AMW-ShowCases.git runs before every pull. The repository on this server predates a few infrastructure decisions, and instead of auditing which transport its remote happens to point at, the deploy script pins it every time. It costs one command and removes a whole class of "pulls fine by hand, fails from Actions" surprises.

Pull, install, source, migrate, collect — in that order. git pull origin main fast-forwards the checkout. pip install -r requirements/prod.txt -q syncs dependencies quietly. Then the line I see copy-pasted wrongly most often:

set -a; source /home/deploy/.env; set +a

set -a marks every variable defined after it for automatic export, so sourcing the environment file puts DATABASE_URL and friends into the process environment; set +a turns the flag back off. Without it, source alone only sets shell variables, and migrate would run without seeing the database connection. Finally migrate --noinput, then collectstatic --noinput, and only then sudo systemctl restart amwshowcase.

That ordering is the point. Everything fragile happens while the old process is still serving traffic: by the time the restart arrives, the disk state is complete and consistent. The new boot either succeeds cleanly or fails loudly in logs — not in front of users.

Static files without drama

The historical Django deployment chore is "run collectstatic, then configure Nginx to serve /static/ from disk." This site uses the other pattern, Whitenoise, with the production STORAGES dict pointing the staticfiles backend at its compressed manifest storage. Django 6 configures storage backends through this single STORAGES mapping, replacing the legacy single-setting assignment that older tutorials still show:

STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

The default entry routes media uploads to an S3-compatible object store (this site's media lives on Cloudflare R2), and the staticfiles entry is why the deploy needs no Nginx static directory: Whitenoise serves the hashed, compressed files directly from the app process and knows the manifest, so a freshly collected file set is picked up without touching the reverse proxy. Nginx still fronts the site as a reverse proxy with TLS from Let's Encrypt, but it no longer owns a static file root to keep in sync. The consequence for the deploy script is pleasant: collectstatic --noinput is the last file operation the deploy performs, and after the restart there is nothing left to coordinate. Old release, new release, hashed filenames — the manifest keeps them from ever getting mixed up.

Secrets: the repo knows how to connect, not what the values are

The GitHub repository holds exactly two secrets: SERVER_IP and SSH_PRIVATE_KEY. They are connection parameters — where to go and how to get in — and nothing else.

Everything the application needs at runtime lives on the server in /home/deploy/.env, outside the web root: SECRET_KEY, DATABASE_URL for the PostgreSQL instance on Neon, and the Cloudflare R2 credentials for media storage. The file is owned by the deploy user with chmod 600, the deploy script sources it (that set -a line), and the systemd unit loads the same file via EnvironmentFile — so the app process and the deploy process see the same environment by construction, not by discipline. Reading the entire repository yields nobody's database password, because the repository genuinely does not contain one.

Verification, then rollback

A deploy is not done when the workflow goes green. It is done when the checks pass:

curl -s -o /dev/null -w "%{http_code}" https://amw.work.gd/health/
# expected: 200

journalctl -u amwshowcase -n 50 --no-pager
# expected: no tracebacks

/health/ proves the new process booted and can reach its dependencies. journalctl proves it booted cleanly — a traceback in the first 50 lines means the restart succeeded and the app is still broken, which is exactly the state you want to catch in the terminal instead of in a user's browser. Then one real page in a real browser, because curl does not run JavaScript-loaded HTMX partials.

Rollback is a habit rather than a procedure: git revert <commit> and push to main, and the same workflow redeploys the previous state within minutes. This works without ceremony because the project's migrations stay additive — new nullable columns and new tables — so reverting to older code, which simply ignores what later migrations added, never fights the schema. Deploy small, revert cleanly.

Key Takeaways

  • One branch deploys, and CI gates it. Everything that reaches main has already passed lint, format checks, Django's system check, a migration dry-run, and a full test suite against real PostgreSQL.
  • Fail fast in the deploy script. set -e turns a half-deploy into a failed workflow run, which is the honest outcome.
  • Pin what you connect to. Host keys in known_hosts, the remote URL on every pull, ruff at an exact version. Non-interactive automation rewards paranoia.
  • Order stages so the restart is the only swap. Install, migrate, and collect while the old process still serves; restart last.
  • Keep runtime secrets in one server-side file, sourced identically by the deploy script and the systemd unit. The repo contains connection parameters, never credentials.
  • Verify with three probes — health endpoint, service journal, one real page — then treat rollback as git revert plus the same pipeline.

This site's full stack — Django 6, Gunicorn, Nginx, Neon PostgreSQL, Cloudflare R2 — is described on the services page. If a hands-off deploy pipeline is what your project is missing, that is the conversation to have.