Tech

Github actions II

After preparing the VPS, the next milestone was the first real production cutover. The Django container was already running beside the existing systemd Gunicorn service, but it was not yet receiving public traffic. This step was about proving the container, changing the smallest possible part of Nginx, and then rebuilding deployment automation around Docker.

Starting the container without touching Nginx

The deployment script built the image, ran Django's deployment checks, verified static-file permissions, collected static files, and started the Compose service on 127.0.0.1:8000.

bash scripts/deploy-container.sh

The final result was a healthy container, while Nginx continued proxying public requests to the old Gunicorn Unix socket. That intermediate state mattered: I could test Docker without putting the live website at risk.

collectstatic reported duplicate CKEditor paths and ignored the later copies. It also reported that thousands of files were already unchanged. These messages were noisy, but they were not deployment failures. The container started normally and remained healthy.

Testing the application directly

Before changing the proxy, I tested the container through its loopback port with the same host and protocol headers that Nginx would forward:

curl --fail \
  --header 'Host: leginabroad.com' \
  --header 'X-Forwarded-Proto: https' \
  http://127.0.0.1:8000/

I checked the homepage, topic pages, search, ads.txt, and the admin login. Public pages returned 200, while the admin returned the expected 302 login redirect. The health endpoint also returned:

{"status": "ok"}

A direct static-file request initially returned an HTTPS redirect because I had omitted X-Forwarded-Proto: https. That was expected behavior from Django's production security settings, not a static-file failure.

The Nginx cutover was one line

The existing Nginx configuration already handled TLS, redirects, and static files correctly. Its application block pointed to systemd Gunicorn through a Unix socket:

location / {
    include proxy_params;
    proxy_pass http://unix:/run/gunicorn.sock;
}

After making a backup, I changed only the upstream:

location / {
    include proxy_params;
    proxy_pass http://127.0.0.1:8000;
}

I validated the configuration before reloading Nginx:

sudo nginx -t
sudo systemctl reload nginx

The public homepage, health endpoint, ads.txt, and CSS all returned 200. Docker's logs then showed requests arriving from the host-side bridge address, confirming that Nginx was now proxying public application traffic to the container. Static CSS did not appear in the application log because Nginx still served /static/ directly, exactly as intended.

Not every error is an application failure

During observation, Django logged repeated DisallowedHost exceptions for requests using the VPS IP address as the HTTP host. These were automated internet scans, not legitimate site traffic.

The correct response was to keep the IP address out of ALLOWED_HOSTS. Django was doing its job by rejecting unknown hosts. A later Nginx hardening change can reject direct-IP HTTPS connections before they reach Django and remove the log noise.

Changing GitHub Actions to deploy Docker

The old GitHub Actions workflow still pulled the repository and restarted systemd Gunicorn. After the proxy cutover, that workflow could report success without updating the container actually serving the website.

The replacement workflow now connects over SSH, pulls the exact revision, runs the container deployment script, waits for Docker health, and verifies the public website. It also serializes production deployments so a newer push cannot interrupt a deployment halfway through.

SSH host verification exposed another useful lesson. Three different keys are involved:

  • The GitHub runner's private key authenticates the deployment user.
  • The matching public key belongs in the server user's authorized_keys.
  • The server's ED25519 host public key lets the runner verify that it reached the correct VPS.

The server host private key must never be copied into GitHub. Only its public half belongs in the known-hosts configuration. The workflow now validates that every SSH secret is present, normalizes accidental line endings, checks that the host key is complete, and produces a specific error instead of failing later with an opaque SSH message.

Where the migration stands

Django is now running in Docker behind the existing Nginx TLS and static-file configuration. PostgreSQL remains private on the VPS, connected through its Unix socket, and the old systemd Gunicorn service remains available as a rollback path.

The next checkpoint is a successful deployment through the new GitHub Actions workflow. After that has been observed, the old Gunicorn service can be disabled. Caddy still comes later as a separate migration, when Docker deployment is routine rather than new.

#CI/CD #Deploy #Docker #Learning
Share on Reddit