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.
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.
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 rundocker login ghcr.ioon the server once with a personal access token that hasread:packagesscope, 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.
# 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:
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.