Stop Building on the Server: Docker Deploys from CI
Most people running their own server update it the same way: SSH in, git pull, docker compose up -d --build. I described that flow myself in deploying Django on AWS Lightsail, because for a single box it really is the shortest path.
The trouble is that it degrades quietly as the project grows. It never looks like an outage — deploys just get slower, occasionally die halfway through, and then one day you need to go back to yesterday's version and discover you have no way to do it.
This post is about the change that fixes all three: build the image in CI, not on the server, and let the server only pull it.
What building on the server actually costs
Memory. When you run docker compose up -d --build, the build shares RAM with the containers that are currently serving traffic. A Next.js build is not a frugal process. With a database and an app already running, a small server pushes the build into swap and, in the worse case, the OOM killer takes it. All you see from outside is a deploy that stopped halfway and a site still serving the old version.
Time. For as long as the build runs on the server, CPU and disk go to it. The site stays up, but it gets slower. You also pay that cost on every deploy, because a single box's build cache is less dependable than CI's — any prune you run to reclaim disk takes the cache with it.
And most importantly, you can't roll back. An image produced by --build has no meaningful tag. An hour later, when you spot the bug, you can't say "go back to the previous image" — you have to revert in git and rebuild. Your time-to-recovery becomes your build time, when rolling back should take seconds.
All three have the same root cause: an image is a build artifact, and you are producing it on the machine that is supposed to be running it.
The right order: CI builds, the server pulls
The flow should be:
- Push to
master. - GitHub Actions builds the image, tags it with the commit SHA, and pushes it to a registry.
- CI connects over SSH; the server pulls that tag and recreates the containers.
The important detail is the commit SHA in step two. Don't deploy latest: it points at a different image after every deploy, so you can't say which code is running on the server right now, and you have no address to roll back to. A commit SHA never moves. "Which version is live" becomes a single readable line.
For the registry, GitHub Container Registry (GHCR) has the least friction — no extra account, and the same token CI already has.
The GitHub Actions side
This goes in .github/workflows/deploy.yml. I am assuming the Dockerfile and Compose layout from running Next.js and Django in production with Docker Compose.
name: Deploy
on:
push:
branches: [master]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push frontend
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: ghcr.io/${{ github.repository }}/frontend:${{ github.sha }}
build-args: |
NEXT_PUBLIC_SITE_URL=https://example.com
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push backend
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxThree details are easy to miss:
The concurrency block. Push twice in quick succession and two deploy jobs run in parallel, with no guarantee about which one has the last word on the server. Leaving cancel-in-progress as false means a deploy already in flight is never cut off mid-way; the second job waits its turn.
The packages: write permission. Required to push to GHCR. Default token permissions vary per repository, and spelling this out saves you a later round of "denied" errors.
The type=gha cache. This keeps the Buildx layer cache in the GitHub Actions cache. Without it, every run reinstalls dependencies and compiles from scratch. Moving the build off the server only to rebuild from zero every time gives back half of what you gained.
Passing NEXT_PUBLIC variables as build args
That build-args line is not decoration. Next.js inlines NEXT_PUBLIC_ variables into the client bundle during next build; setting them in a .env file at runtime changes nothing. I covered that behaviour, and what to do when you want to promote one image across environments, in what changes when you self-host Next.js.
The practical consequence: the moment the build moves from the server to CI, those variables have to be defined in CI too. On the Dockerfile side:
FROM node:22-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run buildNever put a secret in a build arg. Build arg values are visible in the image history (docker history). API keys, database passwords and tokens belong in runtime environment variables on the server. A NEXT_PUBLIC_ value is by definition not a secret — it is shipped to the browser anyway — which is exactly why it is safe here.
The server side: take the tag from outside
In the Compose file, the build block becomes an image line, and the tag is a variable rather than a constant:
services:
frontend:
image: ghcr.io/your-user/your-repo/frontend:${APP_TAG}
env_file: .env
expose:
- "3000"
restart: unless-stopped
backend:
image: ghcr.io/your-user/your-repo/backend:${APP_TAG}
env_file: .env
expose:
- "8000"
restart: unless-stoppedAnd the .env file on the server gains one line:
APP_TAG=9f2c1ab...That line is now the answer to "which version is running". Deploying is nothing more than changing it and recreating the containers.
The server needs to authenticate to GHCR once in order to pull. Use a read-only token for the deploy user — do not copy the write-scoped CI token onto the box:
echo "<READ_ONLY_TOKEN>" | docker login ghcr.io -u your-user --password-stdinThe deploy script
One script on the server, at /usr/local/bin/deploy.sh, with exactly one job:
#!/usr/bin/env bash
set -euo pipefail
: "${APP_TAG:?APP_TAG is required}"
cd /srv/app
sed -i "s/^APP_TAG=.*/APP_TAG=$APP_TAG/" .env
docker compose pull
docker compose run --rm backend python manage.py migrate --noinput
docker compose up -d
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3000/api/health > /dev/null; then
docker image prune -f
echo "deployed: $APP_TAG"
exit 0
fi
sleep 2
done
echo "new version failed its health check" >&2
exit 1set -euo pipefail is not boilerplate here. Without it, a failing docker compose pull does not stop the script: up -d runs against the old image and CI hands you a green check. A deploy that silently does nothing is worse than one that fails loudly.
The health check loop exists for the same reason. docker compose up -d returns when the container has started, not when the application is ready. Skip the loop and a broken release gets reported as a successful deploy.
The matching CI step:
- name: Deploy to server
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
SSH_HOST: ${{ secrets.DEPLOY_HOST }}
SSH_USER: ${{ secrets.DEPLOY_USER }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/id_ed25519 "$SSH_USER@$SSH_HOST" \
"APP_TAG=${{ github.sha }} /usr/local/bin/deploy.sh"Note that the host key comes from a secret instead of ssh-keyscan. Fetching it on the fly trusts whatever answers on the other end; capturing it once yourself and storing it removes that blind trust.
The deploy user should not be root and should not be able to do much else. If the script needs sudo, grant it for that one command in sudoers.
Where migrations go
In the script above, migrate runs before up -d. So the migration runs from the new image while the application is still the old version. That requires migrations to be backward compatible: the new schema has to be readable by the old code.
In practice the rule is: add first, use second, drop last.
- Add columns as nullable or with a default, so code that doesn't know about them can still write rows.
- Split drops and renames across two deploys: in the first, the code stops using the column; in the second, the column goes away.
You could run migrations afterwards instead, but then the new code asks for a column that doesn't exist yet and you serve 500s for those seconds. Writing compatible migrations hurts less than reordering the steps.
What "zero downtime" really means here
This setup cuts deploy time significantly, but if you run a single container it is not zero downtime. docker compose up -d stops the old container for the changed service and creates a new one; there is a gap of a few seconds, and nginx returns 502 during it.
What changed is the size of that gap. It used to be as long as the build — and permanent if the build failed. Now the image is already there, so the gap shrinks to container startup time.
If you genuinely need zero downtime, you have to run two instances behind the proxy and refresh them one at a time. The problems that appear at that point — version skew, the Server Actions encryption key, a shared cache — are covered in what changes when you self-host Next.js. For most single-server projects, a few seconds is not worth that complexity.
Rolling back
This is the real payoff. When you find out a release is broken:
APP_TAG=<previous-commit-sha> /usr/local/bin/deploy.shThe image is still in the registry and may not even need downloading. Recovery time equals container startup time.
For this to work, old images have to still exist. The docker image prune -f in the script only removes dangling layers — ones no tag points to — and leaves tagged older versions alone. Do not use prune -a: it deletes the image you were planning to roll back to.
Rolling back across a migration is a separate matter, since a schema change does not undo itself. If you followed the add-first rule above, the old code keeps working against the new schema and the rollback is clean. That is where the rule actually earns its keep.
Summary
- Don't build on the server. Building is CI's job; the server's job is running.
- Tag images with the commit SHA.
latestleaves "which version is live" unanswered. NEXT_PUBLIC_variables must be defined in CI as build args — and secrets must not be.- Put
set -euo pipefailand a health check loop in the deploy script, or failed deploys will look successful. - Write backward-compatible migrations; that is what makes rollback possible at all.
- One instance means downtime drops to seconds, not to zero. For most projects that is the right trade.