DEV Community

Cover image for Stop Building Docker Images on Your 2 GB Droplet
Vicente G. Reyes
Vicente G. Reyes

Posted on Originally published at vicentereyes.org AI-assisted

Stop Building Docker Images on Your 2 GB Droplet

GiggleGigs is a job board I run: Django + DRF on the backend, React + Vite on the front, Celery for background work, all in Docker, on a single 2 GB DigitalOcean droplet.

Deploys were the cookiecutter-django default: GitHub Actions SSHes into the droplet, runs git pull, then docker compose build, then restarts everything. It's simple, and it worked fine right up to the first time it didn't.

The problem: the droplet was also the build server

Most deploys only change application code, so Docker reuses the cached pip layer and the build finishes quickly. But any change to requirements/*.txt invalidates that layer, and the droplet then has to rebuild every wheel from scratch.

On 2 GB of RAM, that's dangerous. One build compiled psycopg-c from source, ran the box out of memory, and froze it so completely that SSH stopped responding. I had to power-cycle it from the DigitalOcean dashboard.

My quick fixes:

  • Added 2 GB of swap.
  • Switched to psycopg[binary] so nothing compiled from source.
  • Raised the SSH action's command_timeout to 30 minutes, because full rebuilds could outlast the default 10.

That helped, but it treated the symptom. Then I moved the project from Python 3.11 to 3.14 and updated 16 dependencies in one go. That changed the base image and every pin, so the droplet had to download a new Python image and reinstall the whole dependency tree while also serving production traffic. I forgot to stop the containers first, so all I could do was watch and hope.

It survived. But "it survived" isn't a deploy strategy. A server's job is to run the app, and building images is a separate job that belongs on a separate machine.

The goal

  • Build the Django image on GitHub's runners, which have plenty of RAM and cost nothing for this.
  • Push it to GitHub Container Registry (GHCR).
  • Have the droplet pull the image and restart. No compiling there.
  • Make rollbacks one command.
  • Keep changes to the compose file small. It's the file I least want to get wrong.

Step 1: One image instead of four

Like most cookiecutter-django projects, my production.yml had four services built from the same Dockerfile, each with its own image name:

django:
  build:
    context: .
    dockerfile: ./compose/production/django/Dockerfile
  image: job_board_production_django
  command: /start

celeryworker:
  build:
    context: .
    dockerfile: ./compose/production/django/Dockerfile
  image: job_board_production_celeryworker
  command: /start-celeryworker

# ...same again for celerybeat and flower
Enter fullscreen mode Exit fullscreen mode

They're the same image. Only the command is different. So now all four point at a single registry image:

django:
  # Built and pushed by the Deploy workflow; the droplet only pulls it.
  # Roll back with: IMAGE_TAG=<commit sha> docker compose -f production.yml up -d
  image: ghcr.io/reyesvicente/gigglegigs-django:${IMAGE_TAG:-latest}
  build:
    context: .
    dockerfile: ./compose/production/django/Dockerfile
  command: /start

celeryworker:
  image: ghcr.io/reyesvicente/gigglegigs-django:${IMAGE_TAG:-latest}
  command: /start-celeryworker
Enter fullscreen mode Exit fullscreen mode

I kept build: on django only. If GHCR is ever down, I can still run docker compose build django on the droplet as a last resort. The other three just reuse the image.

${IMAGE_TAG:-latest} is the rollback hook. Normally it resolves to latest, and setting it to an older commit SHA pins every service to that build.

Step 2: Build and push in GitHub Actions

The deploy workflow already ran after CI passed on main. It's now two jobs: build runs on a GitHub runner, and deploy SSHes into the droplet.

name: Deploy to DigitalOcean

on:
  workflow_run:
    workflows: ['CI']
    branches: ['main']
    types: [completed]

concurrency:
  group: deploy-production
  cancel-in-progress: false

env:
  IMAGE: ghcr.io/reyesvicente/gigglegigs-django

jobs:
  build:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout the commit CI tested
        uses: actions/checkout@v7
        with:
          ref: ${{ github.event.workflow_run.head_sha }}

      - uses: docker/setup-buildx-action@v4

      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push the Django image
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./compose/production/django/Dockerfile
          push: true
          tags: |
            ${{ env.IMAGE }}:latest
            ${{ env.IMAGE }}:${{ github.event.workflow_run.head_sha }}
          labels: org.opencontainers.image.source=https://github.com/${{ github.repository }}
          cache-from: type=gha,scope=django-production
          cache-to: type=gha,scope=django-production,mode=max
Enter fullscreen mode Exit fullscreen mode

Some of these lines matter more than they look.

Gotcha #1: workflow_run doesn't check out what CI tested

A workflow triggered by workflow_run runs in the context of the default branch. A plain actions/checkout gives you the current tip of main, which isn't necessarily the commit CI just approved. If two merges land close together, you could build and ship code CI never saw.

ref: ${{ github.event.workflow_run.head_sha }} pins the build to the exact commit that passed. I use the same SHA as the second image tag, so every image traces back to a tested commit.

Gotcha #2: Private repo, private package

GiggleGigs is a private repo, so the GHCR package is private too. The org.opencontainers.image.source label links the package to the repo, so the repo's workflows get access to it.

If the first pull fails with denied, open the package's settings on GitHub, go to Manage Actions access, and give the repo Read access. You only do that once.

The cache

cache-from and cache-to with type=gha store BuildKit layers in the GitHub Actions cache. A push that only changes app code reuses the pip layer, the same way the droplet's local cache used to. The difference is that a requirements change now gets rebuilt on a machine that can handle it.

Step 3: The droplet only pulls

  deploy:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      contents: read
      packages: read

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.2.5
        env:
          GHCR_USER: ${{ github.actor }}
          # Short-lived: GITHUB_TOKEN expires when this job ends.
          GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          host: ${{ secrets.DO_HOST }}
          username: ${{ secrets.DO_USERNAME }}
          key: ${{ secrets.DO_SSH_KEY }}
          envs: GHCR_USER,GHCR_TOKEN
          command_timeout: 15m
          script: |
            set -e
            cd ~/GiggleGigs
            git pull origin main
            echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
            docker compose -f production.yml pull django celeryworker celerybeat flower
            docker logout ghcr.io
            docker compose -f production.yml build postgres traefik awscli
            docker compose -f production.yml run --rm django python manage.py migrate
            docker compose -f production.yml up -d
            docker image prune -f
Enter fullscreen mode Exit fullscreen mode

No long-lived registry token on the server

The droplet logs in to GHCR with the workflow's own GITHUB_TOKEN, which appleboy/ssh-action passes through via envs. That token expires when the job ends. The script also runs docker logout straight after the pull, so no credential is left in ~/.docker/config.json.

I didn't need to create a personal access token or store a new secret.

Why git pull still runs

The image contains the code, but the droplet still needs the current production.yml and the Traefik config, and those come from the repo.

Gotcha #3: Don't push your Postgres image

My first idea was to push every image, including postgres, traefik and awscli. These are cookiecutter's small wrapper images: FROM postgres:17 plus a few maintenance scripts copied in.

The problem is that a rebuilt image can get a new digest even when nothing in it changed. When docker compose up -d sees a different image, it recreates the container. That would restart the database on every deploy, for nothing.

So those three still build on the droplet. They don't install anything, they're cached, and they finish in seconds without using much memory. When their Dockerfiles haven't changed, Docker reuses the same image and the containers keep running.

Gotcha #4: Overlapping deploys

Earlier, two merges landed close together and two deploys ran up -d at the same time. Compose left containers with hash-prefixed names like 3f2a…_gigglegigs-django-1, and the next deploy failed with "container name already in use".

The concurrency group at the top of the workflow queues deploys instead:

concurrency:
  group: deploy-production
  cancel-in-progress: false
Enter fullscreen mode Exit fullscreen mode

cancel-in-progress: false matters. Cancelling a deploy halfway through a migration is worse than waiting for it to finish.

A step I deleted

The old script had this:

docker compose -f production.yml run --rm django python manage.py collectstatic --noinput
Enter fullscreen mode Exit fullscreen mode

It runs in a throwaway container. staticfiles/ isn't a volume, so the collected files disappear when that container exits. Meanwhile the container's /start script runs collectstatic again anyway, before gunicorn starts. The step cost time on every deploy and did nothing.

Cleaning up

docker image prune -f removes the dangling images each deploy leaves behind. On a small droplet, old image layers fill the disk quietly until a deploy fails for lack of space.

After the first deploy with the new setup, the old locally built images aren't tagged as dangling, so remove them by hand once:

docker rmi job_board_production_django job_board_production_celeryworker job_board_production_celerybeat job_board_production_flower
Enter fullscreen mode Exit fullscreen mode

Rollbacks

Every image is tagged with its commit SHA, so rolling back is one command on the droplet:

cd ~/GiggleGigs && IMAGE_TAG=<commit sha> docker compose -f production.yml up -d
Enter fullscreen mode Exit fullscreen mode

Before, rolling back meant checking out an old commit on the server and rebuilding. That's the same memory-hungry build I'm trying to avoid, at the worst possible time.

Checking it before merging

Before this reached main:

  • docker compose -f production.yml config --images showed the four app services resolving to the GHCR image, and IMAGE_TAG=abc123 switched all four to :abc123. Postgres, Traefik, Redis and awscli were unchanged.
  • actionlint passed on both workflow files.
  • The production image had already been built and checked locally with production settings during the Python 3.14 upgrade, so the Dockerfile itself was known to work.

The first deploy

I merged to main and timed it. A loop on my laptop also hit the API every 5 seconds, so I could see exactly when the site went down and when it came back.

Step Where Time
Build the image and push it to GHCR GitHub runner 2m 49s
Pull, migrate, up -d, prune Droplet 1m 47s
  • The GHCR pull worked the first time. The source label linked the package to the repo, so I didn't need to change any access settings.
  • Postgres, Traefik and Redis kept running. Their small images rebuilt from cache to the same image, so Compose left those containers alone. Only the four app containers were recreated.
  • The droplet never built a wheel. Its whole job came down to pulling the image and restarting containers. On a requirements change, the expensive part now runs on GitHub's runner instead of next to production traffic.
  • docker image prune -f reclaimed 0 B. That's expected on the first run, since the old images are still tagged under their old names. That's why the manual docker rmi above is needed once.

What this doesn't fix

This removes the risk of the droplet freezing during a build. It doesn't remove the downtime during the restart.

My uptime loop got its last good response at 07:34:28 UTC. The API answered again at 07:36:24, so the site was down for about two minutes.

When Compose replaces the Django container, Traefik has nothing to route to until the new one is ready. My /start script runs collectstatic and django-compressor's compress before gunicorn starts listening, and during that time Traefik answers with a plain no available server. Moving the build to CI doesn't touch any of that, because it all happens after the new container starts.

Now that the image is built in CI, the fix is straightforward: run collectstatic and compress at build time, so the image ships with its static files ready and /start only has to start gunicorn. After that comes a real zero-downtime switch with a healthcheck, starting the new container before stopping the old one. That's for another post.

Takeaways

  • Your production server shouldn't also be your build server. On a small box, one requirements change can turn a deploy into an outage.
  • Pin workflow_run builds to head_sha. Otherwise you can ship a commit CI never tested.
  • One Dockerfile should mean one image. Point every service that shares it at the same tag.
  • Tag by commit SHA. Rollbacks become a single up -d with no rebuild.
  • Use the workflow's own token to log in on the server, and log out after pulling.
  • Don't push images whose digest will change for no reason. Recreating Postgres on every deploy is a bad trade.
  • Remove steps that don't do anything. A throwaway-container collectstatic only looks useful.

GiggleGigs is a job board for gig and contract work: gigglegigs.com.

Top comments (1)

Collapse
 
the_saint_dac63e343ee8704 profile image
The Saint •

The SHA tag rollback is the nicest part. One thing to watch is that migrate runs before up -d, so rolling the image back does not roll the schema back. If a release adds a column or drops one, the old image may break against the new schema. Keeping migrations backward compatible for one release (add first, remove a release later) makes the one-command rollback safe. A fresh database dump taken right before migrate is a cheap safety net too.