Serving Static Sites and SPAs with Nginx

Intermediate
11 min

Serving Static Sites and SPAs with Nginx

Front-end applications compile down to static files, and a static file server is the smallest, fastest thing you can put in a container. This lesson packages a plain HTML site and a Vite-built single-page application into Nginx images, fixes the client-side routing problem every SPA hits, adds caching and a reverse proxy to the API, and shows how to inject configuration at run time without rebuilding.

A Plain Static Site

The official nginx image serves /usr/share/nginx/html on port 80 out of the box, so a static site needs a two-line Dockerfile:

dockerfile
FROM nginx:1.28-alpine COPY site/ /usr/share/nginx/html/
bash
docker build -t my-site . docker run --rm -p 8080:80 my-site

The Alpine variant is around 20 MB. The image already logs access and error output to stdout and stderr (the log files are symlinks to /dev/stdout and /dev/stderr), so docker logs works without configuration.

Building a SPA in a Multi-Stage Image

A React, Vue or Svelte project needs Node.js to build but not to run. The sample Dockerfile builds in a node:22-alpine stage and copies only the dist output into Nginx, so the final image contains no Node.js, no node_modules and no source.

Build-time configuration such as the API base URL is baked in by the bundler. Vite reads variables prefixed with VITE_ from the environment at build time:

bash
docker build --build-arg VITE_API_URL=https://api.example.com -t my-spa .
dockerfile
ARG VITE_API_URL ENV VITE_API_URL=$VITE_API_URL RUN npm run build

Because the value is compiled into the JavaScript, a different environment needs a different image. The last section shows the run-time alternative.

The SPA Routing Problem

A single-page app handles routes like /products/42 in the browser. When a user refreshes that page, the browser asks Nginx for /products/42, which does not exist on disk, and Nginx returns 404. The fix is try_files, which falls back to index.html so the application can take over routing:

text
server { listen 80; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } # Hashed assets can be cached for a year location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; } # index.html must always be revalidated so deployments show up location = /index.html { add_header Cache-Control "no-cache"; } gzip on; gzip_types text/css application/javascript application/json image/svg+xml; }

Save this as nginx.conf and copy it over /etc/nginx/conf.d/default.conf. The assets rule works because bundlers emit hashed filenames; the index.html rule ensures a new deployment is picked up immediately.

Reverse Proxy to the API

Serving the front end and API from one origin avoids CORS configuration entirely. Add a proxy location that forwards to the API service by its Compose name:

text
location /api/ { proxy_pass http://api:3000/; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }

The trailing slashes matter: with /api/ and http://api:3000/, a request to /api/products reaches the API as /products. Without the trailing slash on proxy_pass, the path is forwarded unchanged. Nginx resolves api through Docker's DNS when the container starts, so the API service must exist on the same network; in Compose this is automatic.

Run-Time Configuration and Non-Root Nginx

The Nginx image runs envsubst over any files in /etc/nginx/templates/*.template at start-up and writes the result to /etc/nginx/conf.d/. That lets you change the upstream or a header per environment without rebuilding:

text
# templates/default.conf.template location /api/ { proxy_pass ${API_UPSTREAM}; }
bash
docker run -e API_UPSTREAM=http://api:3000/ -p 8080:80 my-spa

For the JavaScript itself, the common pattern is a config.js generated by the same mechanism and loaded by index.html before the bundle, so window.APP_CONFIG.apiUrl is set at run time. To run without root, use nginxinc/nginx-unprivileged:1.28-alpine, which listens on port 8080 and needs no capability to bind; adjust listen and -p accordingly.

Common Mistakes

  • Forgetting try_files, so deep links work in development (Vite handles them) but 404 in the container.
  • Caching index.html aggressively, so users keep an old bundle that references deleted hashed assets.
  • Copying the whole project into the Nginx image instead of only the build output.
Quick Quiz
Question 1 of 3

Why does refreshing `/products/42` return 404 from a default Nginx configuration?

Key Takeaways

  • A static site is FROM nginx:alpine plus one COPY; a SPA adds a Node build stage and copies only dist.
  • try_files $uri $uri/ /index.html is mandatory for client-side routing.
  • Cache hashed assets for a year and index.html with no-cache.
  • Proxy /api/ to the API service by name to avoid CORS; mind the trailing slashes.
  • Use /etc/nginx/templates/ with envsubst for run-time configuration and nginx-unprivileged to drop root.

Next lesson: Logging Drivers and Debugging Containers — capture, rotate and ship logs, and troubleshoot containers that fail to start.

Serving Static Sites and SPAs with Nginx - Docker | CodeYourCraft | CodeYourCraft