Stop Using nginx as Your Default Static File Server in Docker

In the week of October 21, 2026, government portals in the Netherlands, Finland and Germany, hospital websites and several large companies took their public sites offline. Not because they had been breached, but because two PHP vulnerabilities known as PHP2Shell allowed unauthenticated remote code execution with a single request, and patching every affected system took longer than the risk allowed.

That is a rare event, but not an exotic one. Every runtime that parses untrusted input has this kind of week eventually. The useful question for a team is therefore not “which technology is safe”, but “how much code do we expose that our application does not need”.

For one very common container that question has a clear answer. A compiled React, Angular, Vue or Svelte frontend is a directory of static files. It needs a process that reads files and returns correct HTTP responses. In most repositories that process is nginx, chosen because the first Dockerfile found online used it.

Three rules apply:

  • The runtime should carry only the code paths the application actually exercises.
  • The HTTP contract of the frontend (routing fallback, caching, compression, 404 behavior) must be explicit and tested.
  • The server choice follows from those two points, not from habit or from image-size screenshots.

nginx is not a bad server. For a container whose only job is serving a dist directory, it is simply more server than the job requires, and every unused capability is still patch surface that someone has to track.

Security status: The CVE references in this article reflect public advisories as of October 24, 2026. PHP2Shell (CVE-2026-109618, CVE-2026-109619) was fixed in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 on October 21, 2026. The nginx rewrite-module overflow (CVE-2026-42945) was fixed in nginx 1.30.1 and 1.31.0. Check the vendor advisories for current versions before relying on any number in this text.

This is not a PHP problem

PHP2Shell deserves a plain description, not commentary.

CVE-2026-109618 and CVE-2026-109619 are memory-safety defects in the PHP interpreter. One leaves request state partially initialized after an out-of-memory condition, the other lets request-owned state survive after its memory was freed. Both are rated CVSS 4.0 9.2 and both allow code execution before any application code runs. Exploitation attempts were recorded four days before public disclosure. The PHP project shipped fixes within hours of the leak.

That is a serious vulnerability handled competently by its maintainers. It says nothing about PHP being worse than other technologies, because the same year produced comparable advisories for exactly the servers this article is about:

ComponentAdvisoryClassFixed in
PHPCVE-2026-109618, CVE-2026-109619Unauthenticated RCE, memory corruption8.5.12, 8.4.27, 8.3.36, 8.2.35
nginxCVE-2026-42945Heap buffer overflow in ngx_http_rewrite_module, present since 20081.30.1, 1.31.0
CaddyCVE-2026-27589, CVE-2026-27585, CVE-2026-30852Admin API cross-origin config load, path matcher bypass, variable expansion leaking environment2.11.1 to 2.11.2
Static Web ServerCVE-2025-67487, CVE-2024-32966Symlink path traversal, stored XSS in directory listings2.40.1, 2.30.0

.NET, Node.js, Go and Rust toolchains have their own entries in the same databases. Memory-safe languages remove one class of defects and leave logic errors, dependency vulnerabilities and configuration mistakes in place.

Treating a vulnerability as an occasion for ridicule is unprofessional and, more importantly, useless. The teams that had a calm week during PHP2Shell were not the ones who had picked a “better” language. They were the ones who knew exactly which systems ran PHP, which of them were reachable, and which could be patched or isolated within hours.

That knowledge is the result of a design decision, and it applies to a static frontend container as much as to a PHP application.

Attack surface is a design decision, not a default

A basic security practice is to minimize the potential attack vector. In concrete terms for a container that means:

  1. Run only the process the workload needs.
  2. Compile or enable only the features that process needs.
  3. Expose only the network paths, filesystem paths and privileges that process needs.
  4. Know which advisories affect that process, so a triage takes minutes instead of a meeting.

Developer defaults work against all four points. They optimize for the first successful docker run, not for the fifth year of operation. The nginx:latest image is the clearest example: it is a general-purpose HTTP server with reverse proxying, load balancing, FastCGI, uWSGI, SCGI, gRPC, WebDAV, rewrite, SSI, image filtering, XSLT, mail and stream proxying, plus a root-running master process on port 80.

A frontend container uses none of that except the static file module and perhaps gzip.

CVE-2026-42945 illustrates the cost. The overflow requires a specific rewrite configuration, so a plain try_files SPA configuration was not exploitable. But every team running nginx still had to read the advisory, verify that claim against every nginx configuration in every repository, and schedule the image update. A team running a static file server without a rewrite engine had nothing to verify.

That is the real argument. It is not that nginx is unsafe. It is that an unused module is pure cost: it adds advisories to triage, bytes to scan and a configuration language to review, and it contributes nothing to the response the browser receives.

A static frontend needs a file server, not a web platform

A React application built with Vite produces a dist directory with index.html, fingerprinted JavaScript and CSS bundles and static assets. After the build, React executes in the browser. The container does not need Node.js, npm or the source code.

 1React / Vite source
 2        |
 3        v
 4    npm run build
 5        |
 6        v
 7    dist/
 8      index.html
 9      assets/
10        index-a83f2.js
11        index-b24e1.css
12        logo.svg
13        |
14        v
15    Docker image
16        |
17        v
18    Static HTTP server
19        |
20        v
21    HTTP responses

Build and runtime have different requirements. The build needs a package manager, dependencies and a compiler. The runtime needs to read files and return valid HTTP responses. A multi-stage Dockerfile keeps them apart, and the runtime stage is where the server decision is made.

The same separation applies to Angular, Vue and Svelte with static output, and to plain HTML sites.

What the HTTP contract actually requires

Serving static files is more than returning bytes from disk. A correct static server must:

  • return the right Content-Type for every asset;
  • support conditional requests (ETag, If-None-Match, Last-Modified);
  • send a cache policy that differs between the HTML entry point and fingerprinted assets;
  • compress text responses, dynamically or from precompressed files;
  • return index.html for application routes such as /dashboard/settings; and
  • return 404 Not Found for missing assets such as /assets/missing.js.

The last two points are where most SPA containers are wrong. A global fallback that returns index.html for every missing path produces 200 OK with HTML where a browser expected JavaScript. The failure is silent until a stale cache or a broken deployment turns it into a blank page.

The server is therefore part of the application’s delivery contract. Whatever is chosen must be able to express that contract, and a test must prove that it does.

nginx: capable, but more than the job needs

A minimal nginx configuration for a Vite-generated application looks like this:

 1server {
 2    listen 8080;
 3    server_name _;
 4
 5    root /usr/share/nginx/html;
 6    index index.html;
 7
 8    location /assets/ {
 9        add_header Cache-Control "public, max-age=31536000, immutable";
10        try_files $uri =404;
11    }
12
13    location / {
14        add_header Cache-Control "no-cache";
15        try_files $uri $uri/ /index.html;
16    }
17}

This is correct. Fingerprinted assets get long-lived caching and an explicit 404, application routes fall back to index.html, and the HTML stays revalidatable.

If nginx stays, it should be the unprivileged image maintained by the nginx project. It runs as a non-root user, listens on 8080 and avoids the default image’s writable paths in /var/cache/nginx and /var/run.

Dockerfile
1FROM nginxinc/nginx-unprivileged:1.31-alpine
2
3COPY nginx.conf /etc/nginx/conf.d/default.conf
4COPY --from=build /src/dist/ /usr/share/nginx/html/
5
6EXPOSE 8080

An existing, correctly configured, regularly updated nginx frontend container does not need to be migrated because of this article. The cost of a migration is real, and nginx serves static files efficiently.

The point is different: nginx should not be the default for the next frontend container. The default should be the server whose entire feature set is the feature set the workload needs.

Static Web Server: a runtime built for this workload

Static Web Server (SWS) is a Rust server built specifically for static files. It supports HTTP/1 and HTTP/2, Brotli, Gzip and Zstandard, cache headers, URL rewrites and redirects, SPA fallback, CORS, health checks and optional Prometheus metrics. It has no proxy, no FastCGI and no scripting layer.

The Alpine and Debian images run as an unprivileged sws user by default. The Scratch image contains only the binary and needs more deliberate validation because there is no shell for diagnostics.

As of October 2026, version 2 is the maintained LTS branch and version 3 is under development.

The build stage stays conventional; only the runtime stage changes:

Dockerfile
 1FROM node:24-alpine AS build
 2
 3WORKDIR /src
 4
 5COPY package*.json ./
 6RUN npm ci
 7
 8COPY . .
 9RUN npm run build
10
11
12FROM ghcr.io/static-web-server/static-web-server:2-alpine
13
14COPY --from=build --chown=sws:sws \
15    /src/dist/ /home/sws/public/
16
17COPY --chown=sws:sws \
18    sws.toml /home/sws/sws.toml
19
20ENV SERVER_CONFIG_FILE=/home/sws/sws.toml
21
22EXPOSE 8080

The server is configured through environment variables, command-line arguments or a TOML file. For a reusable image, the file makes the HTTP behavior reviewable:

TOML
 1[general]
 2host = "0.0.0.0"
 3port = 8080
 4root = "/home/sws/public"
 5
 6page-fallback = "/home/sws/public/index.html"
 7
 8compression = true
 9compression-static = true
10
11cache-control-headers = false
12directory-listing = false
13
14health = true
15log-level = "info"
16
17[advanced]
18
19[[advanced.headers]]
20source = "**"
21headers = { Cache-Control = "no-cache" }
22
23[[advanced.headers]]
24source = "/assets/**"
25headers = { Cache-Control = "public, max-age=31536000, immutable" }

Dynamic compression is enabled, precompressed files are served when present, directory listing is off and the two cache policies are explicit.

One limitation must be stated honestly: page-fallback applies to every missing GET request, including missing assets. The example above returns index.html with 200 for /assets/missing.js. Applications that need a strict 404 for missing assets must use SWS’s rewrite rules with a path condition, or choose Caddy, where this distinction is a one-line matcher.

Build and run with the restrictions a static server should always tolerate:

Bash
1docker build -t react-static:demo .
2
3docker run --rm \
4  --publish 8080:8080 \
5  --read-only \
6  --cap-drop=ALL \
7  --security-opt=no-new-privileges \
8  react-static:demo

Production builds pin the runtime image by digest instead of the mutable 2-alpine tag.

Caddy: small configuration, explicit routing

Caddy is written in Go and offers a compact configuration model. It is a general-purpose server with automatic HTTPS, reverse proxying and a runtime admin API, so in scope it sits closer to nginx than to SWS. Two of its 2026 CVEs concerned the admin API and variable expansion, features a static container does not use.

Its advantage for this workload is that the HTTP contract becomes directly readable:

 1{
 2    admin off
 3}
 4
 5:8080 {
 6    root * /srv
 7    encode zstd gzip
 8
 9    @assets path /assets/*
10
11    handle @assets {
12        header Cache-Control "public, max-age=31536000, immutable"
13        file_server
14    }
15
16    handle {
17        header Cache-Control "no-cache"
18        try_files {path} /index.html
19        file_server
20    }
21}

Files under /assets/ must exist and return 404 otherwise. Everything else falls back to index.html. The admin off directive disables the configuration API, which removes an entire class of exposure for a container whose configuration never changes at runtime.

Dockerfile
1FROM caddy:2-alpine
2
3COPY Caddyfile /etc/caddy/Caddyfile
4COPY --from=build /src/dist/ /srv/
5
6EXPOSE 8080

The official image runs as root by default and writes to /data and /config. A hardened deployment adds a USER instruction and mounts those two paths as tmpfs or declares them as volumes. That is a small amount of extra work compared to nginx, where the unprivileged variant already handles it.

Caddy is the right choice when missing-asset behavior, custom headers per path or redirects need to be exact and reviewable, and when the team accepts a broader runtime in exchange for that expressiveness.

Size is not the metric; reachable code is

Minimal images transfer faster and contain fewer packages to scan. That is useful, but it is not the same as a smaller attack surface, and it does not prove runtime performance.

A Scratch image with one binary can still contain a vulnerable HTTP parser. A Debian-based image with a shell can still be the better operational choice when diagnostics have to happen inside the container. A Rust server is not automatically faster than nginx, which is already highly optimized for static delivery.

The metric that matters for security is reachable code: which modules are compiled in, which are enabled by configuration and which accept input from the network. The metric that matters for performance is a measurement under equal conditions: same files, same compression settings, same concurrency.

For a small frontend, the measurable wins almost always come from correct caching and compressed assets rather than from the choice between two efficient servers.

Compression: build-time or request-time

Static content is ideal for precompression because the files never change between requests.

With dynamic compression, the server compresses on every request and spends CPU at delivery time. With precompression, the build generates .gz and .br variants next to the originals:

1dist/
2  index.html
3  assets/
4    index-a83f2.js
5    index-a83f2.js.gz
6    index-a83f2.js.br
7    index-b24e1.css
8    index-b24e1.css.gz
9    index-b24e1.css.br

The server picks the representation that matches Accept-Encoding. SWS uses compression-static = true, Caddy uses the precompressed option of file_server, nginx uses gzip_static and the Brotli module.

The build pipeline must actually generate those files; enabling precompressed-file support does not. Vite plugins such as vite-plugin-compression handle this at build time. The server must also set Content-Encoding and Vary: Accept-Encoding correctly, which all three servers do.

For moderate traffic, dynamic compression is sufficient. Precompression becomes interesting when runtime CPU is a measured constraint.

Caching matters more than the server choice

Two files, two policies:

1/index.html                 Cache-Control: no-cache
2/assets/index-a83f2.js      Cache-Control: public, max-age=31536000, immutable

no-cache does not forbid caching. It requires revalidation before reuse, so the browser discovers a new release through the HTML entry point. immutable with a one-year lifetime is only correct because the filename changes whenever the content changes.

A one-year lifetime on index.html hides releases from users. A short lifetime on fingerprinted assets wastes requests. Both mistakes are independent of Docker and of the server; all three servers in this article express the correct policy in a few lines.

Non-root and read-only are the baseline

A static file server has no reason to write to its filesystem. The content is immutable, logs go to standard output, and there is no cache to persist.

That makes the following the normal operating mode, not a hardening exception:

  • a non-root user with a fixed numeric UID;
  • a read-only root filesystem;
  • an unprivileged port such as 8080;
  • all Linux capabilities dropped; and
  • no-new-privileges set.

SWS Alpine and Debian images and the unprivileged nginx image satisfy the first point out of the box. Caddy needs a USER instruction and writable tmpfs mounts for /data and /config.

None of this replaces patching. A memory-safe binary with a path-traversal bug still serves files it should not. The runtime image must be scanned, pinned by digest and updated when its project publishes an advisory, exactly as PHP had to be updated on October 21.

Test the HTTP contract, not the container startup

A successful docker build proves that an image exists. A running process proves nothing about the responses it returns.

Bash
 1# HTML entry point: 200, text/html, no-cache
 2curl -I http://localhost:8080/
 3
 4# Client-side route: 200, text/html
 5curl -I http://localhost:8080/dashboard/settings
 6
 7# Existing fingerprinted asset: 200, application/javascript, immutable
 8curl -I http://localhost:8080/assets/index-a83f2.js
 9
10# Missing asset: 404, not 200 with HTML
11curl -I http://localhost:8080/assets/missing.js
12
13# Compression negotiated: Content-Encoding and Vary present
14curl -I -H "Accept-Encoding: br, gzip" \
15  http://localhost:8080/assets/index-a83f2.js
16
17# Directory listing disabled
18curl -I http://localhost:8080/assets/

These requests belong in the pipeline, against the final image, with the asset name taken from the real build output. They catch file-permission problems, wrong paths, a missing Vary header and the silent 200-for-missing-asset defect that unit tests cannot see.

The decision

ServerFits whenWatch out for
Static Web ServerNew frontend container, standard SPA routing, smallest reachable codeGlobal fallback also catches missing assets; configure rewrites or accept it
CaddyPer-path rules, exact 404 behavior, readable configurationRoot by default, admin API, broader runtime than the workload needs
nginx unprivilegedExisting nginx estate, team expertise, correct configuration already in placeFull general-purpose module set to triage on every advisory
LighttpdEstablished, lightweight, mature C codebaseFewer current container examples
BusyBox httpdExtreme minimalism for trivial sitesNo SPA fallback, no cache control, no compression

For a new frontend container with standard SPA requirements, Static Web Server is the recommended default. It is designed for exactly this workload, ships rootless images and has nothing to configure that the application does not use.

Caddy is the recommendation when routing behavior has to be exact and reviewable, and the team hardens the image accordingly.

nginx remains correct where it is already running correctly. It stops being correct as the automatic choice for the next container, because a general-purpose web platform is not the minimal runtime for a directory of files.

For a typical React or Vite application:

  • a Node.js build stage and a runtime stage that contains only the server, its configuration and the dist output;
  • a server whose feature set matches the workload, with unused features disabled where the server has them;
  • non-root execution, read-only root filesystem, dropped capabilities and no privilege escalation;
  • no-cache on the HTML entry point and immutable on fingerprinted assets;
  • explicit SPA fallback and explicit 404 for missing assets;
  • compression configured and tested, not assumed;
  • the runtime image pinned by digest, scanned and updated when its project publishes an advisory; and
  • HTTP contract tests against the final image in the pipeline.

PHP2Shell did not punish organizations for using PHP. It punished every organization that could not answer quickly which systems were exposed and could not reduce that exposure within hours. A frontend container that runs a full web platform to serve twenty files is the same problem on a smaller scale: exposure that nobody chose, waiting for the advisory that makes it visible.

Choosing the smallest server that fulfils the contract is not about preferring Rust or Go over C. It is about making the attack surface a decision instead of a default.

For the .NET side of the same system, Containerizing .NET in 2026 Without a Dockerfile and Distroless, Chiseled or Alpine: Choosing a .NET Base Image apply the same principle to the backend image.

Further details are available in the PHP2Shell advisory summary by Help Net Security , the nginx security advisories , the Static Web Server documentation , the Caddy file server documentation and the nginx unprivileged container repository .


Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms

Modernization

Training & Consulting