A Hugo build is a folder of files. Nothing executes, nothing talks to a database, nothing parses user input. The interesting attack surface isn’t the content — it’s the server you put in front of it, and for years that meant an nginx image carrying a config language, a module system, and a package manager I never used.
This blog now runs on static-web-server instead. Here’s the compose file that serves it, and what each hardening line actually buys.
What static-web-server is
SWS is a static file server written in Rust on top of Hyper and Tokio: a single ~4 MB binary with zero runtime dependencies, dual-licensed MIT / Apache-2.0. It does compression, Cache-Control and ETag, byte ranges, directory listings, basic auth, a health endpoint, custom headers, redirects, and virtual hosting — and stops there.
The part that matters for containers is configuration. Everything is settable three ways: CLI flags, a TOML file, or SERVER_* environment variables. That last one is what makes it compose-native — no config file to bind-mount, no template to render at boot.
Three image variants are published: scratch (the default tag), -alpine, and -debian. The scratch image is 10.7 MB and contains exactly one thing:
docker inspect --format '{{.Config.Entrypoint}}' joseluisq/static-web-server:2.44.0
# [/static-web-server]No shell, no libc, no package manager. If someone does find a way to run something, there is nothing there to run.
The container is the attack surface
Four lines do most of the work:
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
volumes:
- ./data:/public:roread_only: true costs nothing here — a static file server never writes. There’s no cache directory, no PID file, no upload path, so there is nowhere to drop a webshell even if you could. Most servers need a tmpfs escape hatch to survive this flag; SWS doesn’t.
cap_drop: ["ALL"] removes the whole capability set. no-new-privileges blocks the setuid escalation path. And the web root is mounted :ro, so the deploy user on the host owns the content and the container only reads it. I won’t re-litigate the general theory here.
The root trap in the scratch image
Here’s the part I got wrong for a while. The upstream docs promise rootless containers, and they deliver — but only for two of the three variants:
The Debian and Alpine Docker images are rootless by default using a dedicated
swsuser and group.
The scratch image is not in that sentence, and its Dockerfile has no USER instruction. You can check yourself:
docker inspect --format 'User=[{{.Config.User}}]' joseluisq/static-web-server:2.44.0
# User=[]
docker inspect --format 'User=[{{.Config.User}}]' joseluisq/static-web-server:2.44.0-alpine
# User=[sws:sws]Empty means root. So the smallest, most locked-down image is also the one that runs as UID 0 unless you say otherwise. The fix is one line:
user: "1000:1000"It works precisely because nothing in this container needs privilege: no file is written, and port 8080 is above the privileged range. The alternative is switching to 2.44.0-alpine, which is rootless out of the box — at 27.6 MB and with a shell in the image. I’d rather keep the smaller blast radius and supply the UID myself.
One caveat worth knowing: you can’t docker compose exec into this container. No shell, nothing to exec. Debugging happens through logs and docker inspect, and that’s the trade.
About that port number
I used to justify SERVER_PORT: "8080" by saying port 80 needs CAP_NET_BIND_SERVICE, which cap_drop: ALL takes away. That’s not true under Docker. Containers get net.ipv4.ip_unprivileged_port_start=0 by default, so a non-root process binds 80 happily with every capability dropped. Restore the classic value and it breaks immediately:
docker run --rm --sysctl net.ipv4.ip_unprivileged_port_start=1024 \
--user 1000:1000 --cap-drop ALL joseluisq/static-web-server:2.44.0
# Caused by:
# Permission denied (os error 13)So 8080 isn’t strictly required — it’s just the choice that keeps working on a hardened daemon, under a rootless runtime, or on Kubernetes, where that sysctl isn’t guaranteed.
Headers and listings
Two environment variables are doing real security work.
SERVER_SECURITY_HEADERS: "true" adds four headers to every response. These are the actual values off the wire:
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-frame-options: DENY
x-content-type-options: nosniff
content-security-policy: frame-ancestors 'self'The default is off, and it flips on automatically when SWS terminates HTTP/2 + TLS itself. That’s the trap in a reverse-proxy setup: Traefik terminates TLS and SWS speaks plain HTTP behind it, so the auto-enable never fires. Forget this line and you silently ship no HSTS.
SERVER_DIRECTORY_LISTING: "false" (also the default, but I like it stated) means a directory with no index.html returns 404 instead of enumerating itself. For a web root you don’t fully control, also set SERVER_DISABLE_SYMLINKS and SERVER_IGNORE_HIDDEN_FILES explicitly rather than trusting a default to stay put.
You get sensible behaviour for free too: cache-control: max-age=86400 and ETags are on by default, and compression negotiates gzip, brotli, zstd, or deflate per request.
TLS at the edge
Traefik handles certificates and routing:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik_network"
- "traefik.http.routers.techblog.rule=Host(`tech.sylvlab.fr`)"
- "traefik.http.routers.techblog.entrypoints=websecure"
- "traefik.http.routers.techblog.tls.certresolver=myresolver"
- "traefik.http.services.techblog.loadbalancer.server.port=8080"Note what’s absent: there is no ports: section. The container publishes nothing to the host and is reachable only over the traefik_network bridge. Traefik is the single ingress, which is the whole point of putting it there.
I also dropped the Traefik compress middleware I’d been carrying over from the nginx setup. SERVER_COMPRESSION defaults to true, so the origin already compresses; Traefik skips anything that arrives with a Content-Encoding anyway. It was two labels doing nothing. Compress in one place and know which one it is.
The full file
networks:
traefik_network:
external: true
services:
techblog:
image: joseluisq/static-web-server:2.44.0
restart: always
read_only: true
user: "1000:1000"
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
environment:
SERVER_PORT: "8080"
SERVER_ROOT: /public
SERVER_SECURITY_HEADERS: "true"
SERVER_DIRECTORY_LISTING: "false"
SERVER_COMPRESSION: "true"
volumes:
- ./data:/public:ro
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik_network"
- "traefik.http.routers.techblog.rule=Host(`tech.sylvlab.fr`)"
- "traefik.http.routers.techblog.entrypoints=websecure"
- "traefik.http.routers.techblog.tls.certresolver=myresolver"
- "traefik.http.services.techblog.loadbalancer.server.port=8080"
networks:
- traefik_networkDeployment is rsync into ./data and docker compose up -d. The image tag is pinned, not :2 and not :latest — 2.44.0 is current as of this writing.
Three things I left out on purpose:
SERVER_LOG_LEVEL: "info"— the default iserror, which means a healthy server logs absolutely nothing. Set it if you want access logs; I let Traefik do that.SERVER_HEALTH: "true"exposes/health. Useful on Kubernetes, redundant here.- A compose
healthcheck:is impossible on the scratch image — no shell, no curl, nothing to probe with. Traefik’s own health checking covers it.
Closing thoughts
The checklist, if you’re copying this:
- Pin an exact image tag, not
:2or:latest - Set
user:explicitly — the scratch variant will not do it for you -
read_only: true,cap_drop: ["ALL"],no-new-privileges:true - Mount the web root
:ro - Set
SERVER_SECURITY_HEADERSexplicitly when TLS terminates upstream - No
ports:— reach the container only through the proxy network - Verify with
curl -Iafter deploying, not by reading the compose file
That last one is the one that matters. Every claim above came from running the container and looking at the response headers, and one of them turned out to be wrong. Static hosting is the easiest thing to secure well, which is exactly why it’s worth doing properly.
If you want something even smaller for the throwaway case — sharing a build directory for ten minutes — I wrote about httpfileserver a while back. For anything that stays up, this is the setup I trust.