Versioned Releases & Auto-Deploy

Tag a GitHub Release, and the server updates to it automatically — with nothing ever reaching into the server from the outside. The server pulls the new version itself, so there's no SSH key to manage, no port to forward on the router, and no inbound firewall rule needed at all.

Why pull beats push for a server that isn't an always-reachable cloud box

The obvious way to "deploy automatically" is to have CI SSH into the server and push the new files there. That works fine for a VPS with a public IP you control — but for a server sitting behind a home router (a laptop, a NAS, anything without its own dedicated public IP), it means fighting port forwarding, dynamic DNS, and exposing SSH to the open internet just to let a GitHub Action in once in a while.

Flip the direction instead: GitHub pushes a built image to a registry (not to the server), and something running on the server periodically checks the registry and pulls when there's something new. Pulling is an outbound connection — the server already does that constantly for any other internet access — so nothing needs to be opened up for the outside world to reach in.

Step 1: the app builds into a Docker image

A small multi-stage Dockerfile: one stage runs the normal build, the other stage is just nginx serving whatever came out of it. The image itself is the deployable artifact — not a folder of files copied over SSH.

Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json build.js nav.config.js ./
COPY src ./src
RUN node build.js

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

Step 2: on release, CI builds and pushes to GHCR — nothing else

GitHub Container Registry (GHCR) is just GitHub talking to GitHub: the workflow authenticates with the automatically-provided GITHUB_TOKEN, so there's no secret to create or rotate for this step, and no network path to the server involved at all yet.

.github/workflows/release-deploy.yml
name: Build & publish Docker image

on:
  release:
    types: [published]

env:
  REGISTRY: ghcr.io

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.release.tag_name }}

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Compute image name (GHCR requires lowercase)
        id: image
        run: echo "name=${REGISTRY}/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ steps.image.outputs.name }}:${{ github.event.release.tag_name }}
            ${{ steps.image.outputs.name }}:latest

Two tags are pushed on every release: the exact version (v1.2.0) so a specific past build can always be pulled on purpose, and latest, which is the one the server actually watches.

By default a GHCR package is private, which means pulling it requires authentication even from your own server. Either run docker login ghcr.io on the server once with a personal access token that has read:packages scope, or — simplest for a personal project — make the package public from its settings page on GitHub after the first push, so the server can pull it with no credentials at all.

Step 3: the server pulls on its own, via Watchtower

Watchtower is a small container whose only job is watching other containers' images and restarting them when a newer version of the same tag appears in the registry. It runs entirely on the server side, polling outward — it never needs anything opened up to it from the internet.

running the docs site, watched by Watchtower
# the site itself — the image name comes from the repo's "Packages" page
# on GitHub after the first release has built
docker run -d \
  --name docs-site \
  --restart unless-stopped \
  -p 8080:80 \
  --label com.centurylinklabs.watchtower.enable=true \
  ghcr.io/<owner>/<repo>:latest

# watchtower itself — only touches containers labeled watchtower.enable=true,
# checks for a new image every 5 minutes, and prunes the old image after swapping
docker run -d \
  --name watchtower \
  --restart unless-stopped \
  -v /var/run/docker.sock:/var/run/docker.sock \
  containrrr/watchtower \
  --interval 300 \
  --label-enable \
  --cleanup

From here, the loop is: tag a release → CI builds and pushes :latest → within 5 minutes Watchtower notices, pulls it, and restarts the docs-site container on the new image. No SSH session, no manual pull, no reload of anything on the host.

Wiring the host's existing nginx to the container

The container listens on 8080 on the host (-p 8080:80) rather than taking over port 80/443 directly, since those are already owned by whatever nginx install is fronting the server's other sites. That host nginx just reverse-proxies to the container instead of serving files from a folder directly:

nginx site block on the host
server {
    listen 80;
    server_name yourdocs.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

This is a one-time change. Once it's in place, nginx is just forwarding to whatever's listening on 8080 — it has no idea a new version even shipped, and never needs touching again.

When this pull-based pattern is worth it over the SSH/symlink version

  • The server is behind a home router, CGNAT, or anything else without a straightforward, stable way to accept inbound connections.
  • You'd rather manage zero SSH keys for CI than one carefully-scoped deploy key.
  • The app is already (or easily) containerized — for something that genuinely has to stay a plain file on disk with no Docker involved, a push-based SSH + atomic symlink swap is still the simpler choice, it just needs the server to actually be reachable from wherever CI runs.