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.

Layers of the final runtime image Three layers. The base nginx unprivileged alpine slim image, about 12 megabytes. The Nginx site configuration, a few kilobytes. The built site files with precompressed copies, about 35 megabytes. The Node runtime, the generator and node_modules from the build stage are not included. What ends up in the image nginx-unprivileged alpine-slim base image ~12 MB default.conf headers, redirects, caching ~4 KB dist/ + .gz files the site ~35 MB
The final image was 47 MB; a single-stage image based on node:20 was 1.1 GB.

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.

Image size and container start time for three Dockerfile approaches Paired bars. A single-stage image based on node 20 was 1,100 megabytes and started in 2.4 seconds. A multi-stage image on the full nginx base was 196 megabytes and started in 0.4 seconds. A multi-stage image on nginx unprivileged alpine slim was 47 megabytes and started in 0.2 seconds. Image size and startup Image size (MB) Start to healthy Single-stage node:20 1100 2.4 s Multi-stage, nginx full 196 0.4 s Multi-stage, alpine-slim 47 0.2 s
Sizes from docker image ls; start time to first successful health check, median of ten starts on the cluster.

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

Request path from a reader to the static site container in the cluster Five steps. The reader connects over HTTPS to the CDN, which serves most requests from cache. Cache misses go to the cluster's ingress controller, which terminates TLS with a managed certificate. The ingress routes by hostname to the docs service. The service load-balances across two Nginx pods on port 8080. Nginx serves the file or the precompressed copy with the site's headers. How a request reaches the container CDN most requests end here Ingress TLS terminated Service by hostname Nginx pods ×2 port 8080 File + headers from the image
TLS, certificates and load balancing live in the platform; the container only serves files.

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

MeasurePrevious image (single-stage)New imageTool
Image size1.1 GB47 MBdocker image ls
Pull time on a new node38 s2 sKubelet events
Start to healthy2.4 s0.2 sReadiness probe timing
High/critical CVEs in image23 (Node, build tools)0Trivy
Memory per pod, idle120 MB (npx serve)6 MBkubectl top
Runs as rootYesNo (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.

Filed underHow-tosnginx