Tech

Dockerizing II

In the first part of this migration, I made the Django application container-ready. Configuration can now come from environment variables, logs go to standard output, and Docker has a lightweight health endpoint at /healthz/.

The second milestone is about proving that the image actually works and preparing a low-risk production cutover. I am still not replacing PostgreSQL, Nginx, TLS, or the GitHub Actions deployment in this step. The objective is narrower: run Django and Gunicorn in Docker behind the existing Nginx configuration, while keeping the old systemd Gunicorn service available for rollback.

Building the image for real

A Dockerfile that looks correct is not enough. The first useful test is a complete image build using the committed lock file:

docker build --tag leginabroad:milestone-1 .

The image built successfully with Python 3.14 and the dependencies locked by UV. I then ran Django's system check inside the image with placeholder configuration values. This verified that the container does not depend on the Git repository or the old secret JSON files.

docker run --rm \
  --env DJANGO_SECRET_KEY=container-smoke-test-only \
  --env DJANGO_SECURE_SSL_REDIRECT=false \
  --env POSTGRESQL_NAME=unused \
  --env POSTGRESQL_USER=unused \
  --env POSTGRESQL_PASSWORD=unused \
  --env HOST_NAME=host.docker.internal \
  --env PORT=5432 \
  leginabroad:milestone-1 \
  python manage.py check

The database values are deliberately unused in this smoke test. Django can load its configuration and run system checks without querying PostgreSQL.

Verifying Gunicorn and the container user

Next, I started the image on a local port 8001 and called the health endpoint:

curl http://127.0.0.1:8001/healthz/

{"status": "ok"}

I also checked the process identity inside the running container:

uid=10001(app) gid=10001(app) groups=10001(app)

This matters because an internet-facing application should not run as root when it does not need root privileges. Gunicorn can listen on port 8000 as an ordinary user, and Nginx remains responsible for public ports 80 and 443.

The production Compose overlay

The base Compose file remains convenient for local work. Production adds a second file:

docker compose \
  --env-file .env.docker \
  -f compose.yaml \
  -f compose.production.yaml \
  up -d

The production overlay applies several restrictions:

  • The container root filesystem is read-only.
  • Linux capabilities are dropped.
  • no-new-privileges prevents privilege escalation.
  • A small temporary filesystem is mounted at /tmp for Gunicorn.
  • The application is published only on 127.0.0.1:8000.
  • Container logs are rotated instead of growing without a limit.

Binding to 127.0.0.1 is important. The Django container should not bypass Nginx and become directly reachable from the internet.

Static files need an explicit plan

The existing Nginx configuration already serves collected static files from the server. A Docker named volume would hide those files from host Nginx, so the production overlay bind-mounts the existing repository static directory into the container:

services:
  web:
    volumes:
      - ./static:/app/static

Django can collect static files into this directory, while Nginx continues reading them from the same host path. Because the container runs as UID and GID 10001, the directory must be prepared once:

mkdir -p static
sudo chown -R 10001:10001 static
sudo chmod -R u=rwX,go=rX static

This avoids changing the static-file architecture during the application cutover. I can reconsider whether Caddy, WhiteNoise, or a dedicated static service should own this responsibility later.

A repeatable deployment script

I added a deployment script for the container milestone:

bash scripts/deploy-container.sh

It performs the following checks and actions:

  1. Requires the server environment file to have permissions 600.
  2. Builds the image from the locked dependencies.
  3. Runs manage.py check --deploy.
  4. Verifies that UID 10001 can write the static directory.
  5. Optionally applies reviewed migrations.
  6. Runs collectstatic.
  7. Starts the container.
  8. Waits until Docker reports the service as healthy.

Migrations are not automatic. A deployment containing reviewed migrations must request them explicitly:

bash scripts/deploy-container.sh --migrate

This is intentional. Application rollback and database rollback are different problems, and a schema change needs its own tested recovery plan.

Starting Docker before changing Nginx

The script stops after starting a healthy container. It does not edit Nginx and it does not stop the current systemd Gunicorn service. That creates a useful intermediate state:

Internet
    |
    v
Nginx ──────────> systemd Gunicorn

127.0.0.1:8000 ─> Docker Gunicorn (healthy, not receiving public traffic)

At this point I can test the new application directly from the server:

curl --fail http://127.0.0.1:8000/healthz/

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

The forwarded-protocol header verifies that Django behaves correctly when HTTPS is terminated by Nginx.

The smallest possible Nginx cutover

After the direct checks pass, the Nginx change is limited to the application upstream:

location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

The domain names, TLS certificates, HTTP-to-HTTPS redirect, and static-file location stay unchanged. Nginx must validate the configuration before it reloads:

sudo nginx -t
sudo systemctl reload nginx

A reload is preferable to a hard restart because existing connections are handled gracefully.

Rollback comes before cleanup

I will keep the original Nginx site configuration and the systemd Gunicorn service during the first observation period. If the container fails, the rollback order is important:

  1. Restore the previous Nginx configuration.
  2. Restart the systemd Gunicorn socket and service.
  3. Validate and reload Nginx.
  4. Verify the public site.
  5. Only then stop the Docker service.

Stopping Docker before restoring the old Nginx upstream would create an unnecessary outage.

What this milestone deliberately does not change

  • PostgreSQL remains on the host.
  • Nginx still handles TLS and public traffic.
  • The old Gunicorn service remains available for rollback.
  • GitHub Actions still uses the current deployment path.
  • Caddy is not introduced yet.

Keeping those boundaries makes the result easier to understand. If the container works locally but fails behind Nginx, I know where to look. If PostgreSQL connectivity fails, I have not simultaneously changed the database deployment.

What comes next

Next, I'll do a manual VPS trial and Nginx cutover. Once it's been stable long enough to trust, I can update GitHub Actions to build and restart the Compose service automatically.

Caddy still comes later. Replacing the reverse proxy at the same time as introducing Docker would remove the main benefit of this staged migration: knowing which change caused a problem.

The most useful lesson so far is that containerization is not primarily about writing a Dockerfile. It is about making configuration, permissions, networking, health checks, static files, and rollback behavior explicit.

#Deploy #Docker #Learning
Share on Reddit