Serving a Static Site From a Docker Container With Nginx
Static sites do not need containers — a CDN or object store serves them perfectly well. But many teams deploy everything to a container platform: a Kubernetes cluster, Cloud Run, Fly.io, or an internal platform where "a service" means "an image". There, the static site ships as an Nginx container like everything else, and the question becomes how to build a small, secure, well-configured image rather than a 900 MB one that runs as root with the default Nginx configuration.
This guide packages an Astro documentation site as an Nginx container for a Kubernetes cluster: a multi-stage Dockerfile, a non-root Nginx with a read-only filesystem, the site's headers and redirects baked into the image, health checks, and measured image size and startup. It belongs to Self-Hosting Static Sites on S3, Nginx and Caddy; the Nginx configuration itself is covered in Serving a Static Site with Nginx.
Prerequisites
- Docker (or another OCI image builder) and a registry the platform can pull from.
- A static site whose build runs in Node (here Astro 5) and outputs to
dist/. - An ingress or load balancer in front of the container that terminates TLS.
Step 1: A Multi-Stage Dockerfile
# --- build stage: Node and the generator, discarded afterwards
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build && \
find dist -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.json' \) \
-exec gzip -9 -k -n {} \;
# --- runtime stage: Nginx and the built files only
FROM nginxinc/nginx-unprivileged:1.27-alpine-slim
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
The build stage has Node, the generator and node_modules; none of it reaches the final image. nginx-unprivileged runs as user 101 on port 8080 with its pid file and temp paths already writable. Files are precompressed with gzip at build time; Brotli needs an Nginx build with the module, which the slim image lacks — see Brotli and Gzip Precompression Served by Nginx for when that is worth a custom image.
Step 2: The Nginx Configuration
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
gzip_static on;
gzip_vary on;
absolute_redirect off; # behind a proxy, keep redirects relative
server_tokens off;
location = /healthz { access_log off; return 200 "ok\n"; }
location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "public, max-age=0, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
try_files $uri $uri/index.html =404;
}
error_page 404 /404.html;
include /etc/nginx/conf.d/redirects.inc;
}
Two container-specific settings: absolute_redirect off makes Nginx's own redirects (for example, adding a trailing slash) relative, so they do not leak the internal :8080 port behind the ingress; and the dedicated /healthz location gives the platform a cheap health endpoint that is not a real page. Security headers are repeated in each location because of Nginx's add_header inheritance rule, as explained in Cache-Control Headers for Static Sites in Nginx. Redirects are generated at build time into redirects.inc.
Step 3: Run Read-Only and Unprivileged
In Kubernetes, the pod runs with a read-only root filesystem and no privileges; Nginx only needs a few writable temp directories, mounted as emptyDir volumes:
securityContext: { runAsNonRoot: true, runAsUser: 101, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false,
capabilities: { drop: [ALL] } }
volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: cache, mountPath: /var/cache/nginx }
readinessProbe: { httpGet: { path: /healthz, port: 8080 }, periodSeconds: 5 }
resources: { requests: { cpu: 20m, memory: 16Mi }, limits: { memory: 64Mi } }
A read-only root filesystem means even a compromised Nginx process cannot modify the site's files. The resource requests reflect measured use: about 6 MB of memory per pod at idle and a few millicores of CPU, since the ingress and the CDN in front absorb most traffic.
Step 4: Build and Ship
The CI job builds with BuildKit's cache mounts, tags the image with the commit SHA, scans it and pushes:
docker buildx build --cache-from type=registry,ref=$REG/docs:cache --cache-to type=registry,ref=$REG/docs:cache,mode=max \
-t $REG/docs:$GITHUB_SHA --push .
trivy image --exit-code 1 --severity HIGH,CRITICAL $REG/docs:$GITHUB_SHA
Deploying is a change to the image tag in the cluster's manifest; Kubernetes rolls out new pods and keeps old ones until the new ones pass readiness, so there is no moment without a working site. Rollback is setting the previous tag. Because the image contains the server configuration, staging and production run byte-identical configurations — a header or redirect change is tested in staging exactly as it will run in production.
Request Path in the Cluster
Two replicas run at all times, spread across nodes by a pod anti-affinity rule, so a node failure or a rollout never leaves the site without a serving pod. The CDN in front caches HTML for a short time and assets for a year, so the pods themselves serve a few requests per second; a horizontal autoscaler was considered and judged unnecessary at that load.
Logs go to standard output in Nginx's combined format, which the cluster's log collector already parses. The ingress adds an X-Request-ID header that Nginx logs, so a single request can be followed from the CDN's logs through the ingress to the pod when diagnosing a problem.
Measured Impact
| Measure | Previous image (single-stage) | New image | Tool |
|---|---|---|---|
| Image size | 1.1 GB | 47 MB | docker image ls |
| Pull time on a new node | 38 s | 2 s | Kubelet events |
| Start to healthy | 2.4 s | 0.2 s | Readiness probe timing |
| High/critical CVEs in image | 23 (Node, build tools) | 0 | Trivy |
| Memory per pod, idle | 120 MB (npx serve) | 6 MB | kubectl top |
| Runs as root | Yes | No (UID 101, read-only FS) | Pod spec |
The previous image had used npx serve inside a Node image to serve dist/, which carried the whole build toolchain into production along with its vulnerabilities.
Pull time mattered most during node replacements: with the small image, a new node served the site within a few seconds of joining the cluster, instead of waiting over half a minute for a gigabyte to download.
Pitfalls & Rollback
- Single-stage images. The build toolchain ends up in production with its CVEs.
- Root and writable filesystems. Use the unprivileged image and a read-only root.
- Absolute redirects behind a proxy. They expose the internal port; set
absolute_redirect off. - Health checks against real pages. Use a dedicated endpoint so checks do not distort logs or caches.
- Rollback: deploy the previous image tag; the cluster rolls back pod by pod with no downtime.
Conclusion
A static site in a container should be a small Nginx image with the built files and the server configuration, nothing else. A multi-stage build, the unprivileged slim base, precompressed files, a read-only filesystem and a dedicated health endpoint took this site's image from 1.1 GB with 23 high-severity CVEs to 47 MB with none, starting in a fifth of a second.
FAQ
Why put a static site in a container at all?
Because the platform you deploy to runs containers: Kubernetes, a container platform such as Cloud Run or Fly.io, or an internal hosting system that expects images. A container also bundles the server configuration with the site, so headers, redirects and caching travel with every deploy and are identical in staging and production.
How small can a static site container be?
The nginx alpine-slim base image is about 12 megabytes. With the site's files added, the image in this guide was 47 megabytes, most of it images. A multi-stage build keeps Node, the generator and node_modules out of the final image, which otherwise adds hundreds of megabytes.
Can Nginx run as a non-root user in a container?
Yes, by listening on an unprivileged port such as 8080 and moving the pid file and temporary directories to writable locations. The official nginx-unprivileged image is preconfigured this way. Running as non-root limits what a compromise of the server process could do.
Where should TLS terminate for a containerised static site?
Usually outside the container, at the platform's load balancer, ingress controller or reverse proxy, which manages certificates for many services. The container serves plain HTTP on an internal port. Terminating TLS inside every container means managing certificates per container, which platforms are designed to avoid.
Related
- Parent: Self-Hosting Static Sites on S3, Nginx and Caddy — running your own hosting.
- Serving a Static Site with Nginx — the Nginx configuration in depth.
- Running Jekyll in Docker for Reproducible Builds — containers for the build side.
- Auditing npm Dependencies in SSG Pipelines — keeping the build stage clean too.
- Nginx vs Caddy for Serving Static Sites — the alternative server.