Small, hardened nginx image for hosting static sites (Astro, Hugo, plain HTML) from /app.
178
A small, hardened nginx image for serving static sites (Astro, Hugo, Jekyll,
plain HTML). Mount your built site at /app and it's served on port 8080
by an unprivileged user, ready to run with a read-only root filesystem behind
a reverse proxy such as Traefik.
nginx:1.30-alpine (stable)nginx (UID/GID 101) by default; any non-root UID works8080/app (shows a placeholder page until you mount a site)GET /healthz returns 200 okSource and issues: github.com/rathinosk/nginx-static
| Tag | Meaning |
|---|---|
1.30 | nginx 1.30. Pin production to a version tag. |
latest | The most recent build from main. |
A new nginx release gets a new version tag, so pinned deployments only change when you update them.
Try it with the built-in placeholder page:
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp rathinosk/nginx-static:1.30
Then open http://localhost:8080.
Serve a built site (for example Astro's dist/ folder):
mkdir -p logs && sudo chown 101:101 logs
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp \
-v "$(pwd)/dist:/app:ro" \
-v "$(pwd)/logs:/logs" \
rathinosk/nginx-static:1.30
--read-only --tmpfs /tmp is recommended: nginx keeps its pid and temp files
in /tmp and writes logs to /logs, so nothing else needs to be writable.
services:
web:
image: rathinosk/nginx-static:1.30
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
mem_limit: 64m
cpus: 0.5
ports:
- "8080:8080"
volumes:
- ./html:/app:ro # your built site
- ./nginx:/etc/nginx/site.d:ro # optional per-site config
- ./logs:/logs # must be writable by UID 101
mkdir -p html nginx logs && sudo chown 101:101 logs
docker compose up -d
A full example behind Traefik (Let's Encrypt, www → apex redirect) is in the
repository's docker-compose.yml.
| Path | Purpose | Mode |
|---|---|---|
/app | Your static site | read-only |
/etc/nginx/site.d | Optional *.conf files included in the server block | read-only |
/logs | access.log and error.log | writable by the container's UID |
| Variable | Default | Purpose |
|---|---|---|
LOG_RETENTION_DAYS | 90 | Days to keep compressed rotated logs |
/about serves about.html or about/index.html.404.html as the error page, if it has one.absolute_redirect off), so /dir → /dir/ is correct
behind a proxy.gzip_static for pre-compressed .gz files.X-Forwarded-For (private proxy ranges trusted).Cache-Control is set by path:
| Path | Value |
|---|---|
/_astro/* (hashed build assets) | public, max-age=31536000, immutable |
*.css, *.js, *.mjs | public, max-age=604800 (7 days) |
Images, fonts, media, .pdf, .zip | public, max-age=2592000 (30 days) |
| Everything else, including HTML | no-cache |
Sent on every response, including errors:
Strict-Transport-Security: max-age=31536000X-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()Cross-Origin-Resource-Policy: same-siteA Content-Security-Policy is site-specific, so add your own in site.d.
.git, .env, .htaccess, …) return 404; /.well-known/ is
still served for ACME and security.txt..php, .asp, .jsp, .cgi, .env,
.ini, .log, .bak, .sql, .conf, .sh, .swp) return 404, even if
one ends up in /app./wp-admin, /wp-login, /phpmyadmin, /xmlrpc.php,
…) return 404 and aren't logged.nikto, sqlmap, dirbuster, w3af,
censys, python-requests) get 403.Mount a folder of *.conf files at /etc/nginx/site.d/. They're included
inside the server block, before the image's own locations, so they can add
headers, redirects and locations:
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'" always;
location = /old-page { return 301 /new-page; }
location ^~ /old-section/ { return 301 /new-section/; }
Keep add_header at server level (outside any location). nginx drops
server-level headers in a location that sets its own, which would silently
remove the security and cache headers for those URLs.
Exact-match (=) and ^~ locations win over the image's blocking rules, so
redirects from old URLs like /index.php still work.
Test and reload without restarting:
docker exec <container> nginx -t
docker exec <container> nginx -s reload
/logs/access.log and /logs/error.log.access.log-YYYYMMDD.gz.LOG_RETENTION_DAYS are deleted.The /logs folder must be writable by the container's UID. If it isn't, the
container exits at startup and prints the chown command to fix it. If
nothing is mounted at /logs, Docker uses an anonymous volume.
nginx only writes to /tmp and /logs, so any non-root UID works. For
example, to run as www-data (UID 33):
user: "33:33"
sudo chown 33:33 logs
The site and site.d files only need to be world-readable.
nginx is distributed under the 2-clause BSD license. Like all Docker images, this one also contains other software (Alpine Linux, BusyBox, tini) under their own licenses.
Content type
Image
Digest
sha256:c397792ad…
Size
24.9 MB
Last updated
about 5 hours ago
docker pull rathinosk/nginx-static