Serving this site from a container behind the host's nginx
This site is a static Zola build. It runs as a container on a small VPS that already hosts other services behind one nginx. This post covers how that is wired up, the deploy and rollback path, and a stylesheet bug on 3 November that my health check could not see. The site's source repo is private, so the excerpts below are trimmed, lightly reformatted copies of the real files, not links to them.
Status: shipped. This setup has served the site since 1 November 2026. It is one small static site on one host. I have no load figures, and the numbers below come from single measurements.
How this was done
Claude Code, Anthropic's coding agent, wrote most of the Dockerfile, the Compose file, the nginx configs and the deploy script, and it ran the builds, tests and checks described here. I decided to containerize the site (I wanted the same artifact on my desktop and on the server, with rollback as a tag), reviewed each stage, approved each deploy, and decided what goes in this post.
Why a container for a static site
My first plan was zola build and rsync --delete into a web root served directly by the host's nginx. That works, but the result exists on the server only as loose files, and the deploy is a destructive sync into a shared directory tree that must never be pointed at the wrong place. My other services on that host already run as loopback-only Compose stacks behind the same nginx, so a container fit how the host is used.
What I get: the image I test locally is the thing that runs, and a rollback is a tag. What it costs: Docker is on the deploy path, and every deploy ships the whole image (about 60 MB on disk) instead of changed files.
The image
Two stages: build the site with Zola, then copy the output into an unprivileged nginx. Both base images are pinned by tag and digest.
FROM ${ZOLA_IMAGE} AS build
WORKDIR /site
COPY config.toml ./
COPY content ./content
COPY static ./static
COPY templates ./templates
RUN ["/zola", "check", "--skip-external-links"]
RUN ["/zola", "build", "--output-dir", "/out"]
FROM ${NGINX_IMAGE}
COPY deploy/container/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/container/security-headers.conf \
/etc/nginx/snippets/security-headers.conf
COPY --from=build --chmod=a+rX /out /srv/sitezola check fails the build on a broken internal link. External links are skipped, so a flaky third-party site cannot block a deploy. The final image holds static files and nginx. The --chmod on the last line is there because of the bug described below.
The container
services:
site:
image: rknepp-site:${SITE_TAG:-local}
restart: unless-stopped
ports:
- "127.0.0.1:${SITE_PORT}:8080"
mem_limit: ${SITE_MEM_LIMIT:-64m}
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null",
"http://127.0.0.1:8080/healthz"]The port is bound to loopback on purpose. Writing "${SITE_PORT}:8080" would bind every interface, and Docker adds its own firewall rules for published ports, which can bypass host firewall rules such as ufw's. The process runs as the base image's non-root user, with a read-only root filesystem, no capabilities and a 64 MiB memory cap. The cap is a guess, which is why the memory section below matters.
Where the headers live
The container's nginx sets the content security policy (same-origin styles and images only), nosniff, the referrer and permissions policies, and the cache rules, so they travel with the image. The host's nginx terminates TLS, redirects www to the apex, adds HSTS, and proxies to the container's loopback port.
One nginx rule shapes the container config: as soon as a location defines its own add_header, nginx drops every add_header it would have inherited. So the security headers live in a snippet that each location includes:
location / {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ =404;
}The cost is that headers now exist in two places, the container and the host, so a change to either has to be checked against the other.
Why /healthz is not public
The container healthcheck and the deploy script both read /healthz on the loopback port. The host's public server block answers it with a 404:
location = /healthz {
return 404;
}I return the 404 outright rather than restricting the path by client address.
Deploy and rollback
scripts/deploy.sh does this, in order:
- Runs a content lint (dashes, private address ranges, phone-number patterns) and refuses to deploy a dirty git tree unless told to.
- Builds the image and tags it with the 12-character git SHA.
- Checks that every file under
/srv/sitein the built image is readable by the runtime user. - Streams the image to the host, with no registry:
docker save "$IMAGE:$tag" | gzip \
| ssh "$DEPLOY_HOST" "gunzip | docker load"- On the host, writes the tag into
.env, runsdocker compose up -d --no-build, waits up to 30 seconds for the container to report healthy, checks/healthzon loopback, fetches every stylesheet the home page links, and keeps only the newest three image tags.
Rollback reruns the host side of step 5, minus the pruning, with an older tag that is already on the host. There is no build and no transfer:
DEPLOY_HOST=<SSH_ALIAS> DEPLOY_DIR=<REMOTE_DIR> \
scripts/deploy.sh --rollback <TAG>It was exercised on 3 November: the live site was rolled back to an earlier build and forward again, each direction taking about 9 seconds, with the health and stylesheet checks passing both ways. That is one run each. I have not rolled back from a broken release; the one bad deploy so far was fixed by correcting the file and deploying again.
What broke on 3 November
A deploy carrying a stylesheet change left /css/style.css returning a 403 for a few minutes, and the site rendered unstyled. None of the checks that ran before or during the deploy noticed.
The cause was a file mode. The patched stylesheet was written onto my desktop by a file-transfer tool the agent used, which created it with mode 600. Zola copies static files with their source mode, and the container's nginx runs as an unprivileged user, so it could not read the file. Git does not distinguish 600 from 644, so the diff showed nothing. The container healthcheck only requests /healthz, so it stayed green, and the deploy dry run passed as well. The agent reported the deploy as done before anyone had fetched the live stylesheet. What caught it was the accessibility run that follows each deploy.
There was a second problem. The 403 was sent with Cache-Control: public, max-age=604800, because the CSS location set that header with always, which applies it to error responses too. In a Chromium test, three page loads produced one request per stylesheet, so the browser had cached the 403 like a 200. Anyone who loaded the site in that window could have kept the broken stylesheet for up to a week at that exact URL. The next commit changed the stylesheet's content, which changed Zola's cache-busting query string, so that URL stopped being requested. The test was Chromium only, and I do not know how many visitors were affected.
Three guards went in. The COPY --chmod=a+rX in the Dockerfile makes everything world-readable. The build-time check from step 3 reads every file in the image as the runtime user:
unreadable="$(docker run --rm --entrypoint sh "$IMAGE:$tag" -c '
for f in $(find /srv/site -type f); do
head -c 1 "$f" >/dev/null 2>&1 || echo "$f"
done')"
if [[ -n "$unreadable" ]]; then
echo "files in the image that the runtime user cannot read:" >&2
echo "$unreadable" >&2
exit 1
fiAnd after activation, the script fetches each stylesheet through the container and fails unless every one returns 200:
for p in $paths; do
code="$(curl -s -o /dev/null -w '%{http_code}' "${base}${p}" || true)"
echo "stylesheet ${p%%\?*}: ${code}"
[ "$code" = 200 ] || { echo "SMOKE TEST FAILED" >&2; exit 1; }
doneI tested the guards against a deliberately broken image with style.css at mode 600. The image check named the file, and the post-deploy check failed with the 403. Both passed on the next real deploy.
After that, I fixed the header. The CSS location no longer sends always on Cache-Control, so nginx adds it only to success and redirect responses:
location ~* \.(css|svg)$ {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
}I checked this against a local container with the stylesheet made unreadable to the runtime user. Before the change, the 200 and the 403 both carried the week-long header. A 404 did not, because error_page 404 is served from a separate internal location with its own headers. After the change, only the 200 carries it. The security headers still use always, since they should be on error pages too.
The lesson is plain: a health check that proves the process is up says nothing about the files it serves. The check has to fetch what a visitor fetches.
What I measured, and what I did not
- Image size, from
docker image inspect: 60.2 MB on my desktop (Docker 29.9.2) and 61.6 MB on the server. - Idle memory, from
docker stats --no-stream: about 10 MiB on the desktop, and 5.6 MiB on the server 15 seconds after the container started, against the 64 MiB cap. The server reading is one early sample, not a steady-state measurement. I plan to recheck after about a week of uptime and add the reading here. - The served files were byte-identical to a local
zola build(diff -rq).
Not measured: memory under load or after days of uptime, build time, deploy transfer time, and request latency.
Alternatives, and when I would change this
I considered keeping rsync to a web root. It is the simplest thing that works and needs nothing extra on the host. I rejected it because it is not portable and the deploy is a destructive sync into a shared tree. I did not evaluate a registry-based deploy, a different web server, or serving the container directly on a public port.
I would revisit this if the container contributes to memory pressure on the host, if I need to deploy from CI or to more than one host (which would call for a registry), or if the site moves to a host that does not run Docker.