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.
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:
FROM nginx:1.28-alpine
COPY site/ /usr/share/nginx/html/docker build -t my-site .
docker run --rm -p 8080:80 my-siteThe 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.
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:
docker build --build-arg VITE_API_URL=https://api.example.com -t my-spa .ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run buildBecause the value is compiled into the JavaScript, a different environment needs a different image. The last section shows the run-time alternative.
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:
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.
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:
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.
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:
# templates/default.conf.template
location /api/ {
proxy_pass ${API_UPSTREAM};
}docker run -e API_UPSTREAM=http://api:3000/ -p 8080:80 my-spaFor 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.
try_files, so deep links work in development (Vite handles them) but 404 in the container.index.html aggressively, so users keep an old bundle that references deleted hashed assets.Why does refreshing `/products/42` return 404 from a default Nginx configuration?
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.index.html with no-cache./api/ to the API service by name to avoid CORS; mind the trailing slashes./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.