Hardened unprivileged nginx for static sites (Astro SSG) behind Cloudflare and nginx-proxy
885
Hardened, unprivileged nginx runtime for prebuilt static sites (Astro SSG and similar) served behind
Cloudflare and nginx-proxy. The image holds the
server config; each site supplies only its built files through a read-only bind mount and a few
environment variables.
linux/amd64, linux/arm64nginxinc/nginx-unprivileged (alpine), pinned by version and digest101, listens on 8080, works with read_only: truedocker run --rm -p 8080:8080 --read-only --tmpfs /tmp \
-v "$PWD/dist:/usr/share/nginx/html:ro" \
astrotechdevs/static-sites:1.2
Open http://localhost:8080. The container exits at start with a clear error when
index.html is missing or unreadable.
services:
site:
image: astrotechdevs/static-sites:1.2
restart: unless-stopped
environment:
- VIRTUAL_HOST=example.com
- LETSENCRYPT_HOST=example.com
volumes:
- ./dist:/usr/share/nginx/html:ro
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
networks:
- reverse-proxy
networks:
reverse-proxy:
external: true
The image already sets VIRTUAL_PORT=8080 and HSTS=off (Cloudflare owns HSTS) for
nginx-proxy. Mounted files must be readable by uid 101 (directories 755, files 644), otherwise nginx
answers 403.
Security headers on every response, including assets, redirects and error pages: CSP,
X-Content-Type-Options: nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy.
HSTS is left to Cloudflare.
Cache per asset class:
| Path | Cache-Control |
|---|---|
/_astro/* | public, max-age=31536000, immutable |
Hashed video (clip.a1b2c3.mp4; mp4 webm ogg mov m4v) | public, max-age=31536000, immutable, byte ranges |
| Other css/js/mjs/fonts/images/ico and unhashed video | public, max-age=86400, must-revalidate |
/.well-known/* | public, max-age=3600 |
| HTML and everything else | public, max-age=0, must-revalidate |
/healthz | no-store; 200 while index.html exists, 503 otherwise |
Missing URLs return 404 with the site's own /404.html (no SPA fallback), including directory
URLs without an index.html.
Safe to mount a deploy root that also holds non-site files: dotfiles (/.env), /docker/,
backups, dumps, logs, archives, config files, keys and project manifests (package.json,
astro.config.*…) answer 404, and symlinks leaving the mount are not followed. .zip and .md
stay public. Mounting only the build output is still the tightest option.
/.well-known/ is public (security.txt, assetlinks.json, apple-app-site-association as JSON).
Correct types for webmanifest, mjs (module scripts) and md; text types are utf-8.
/sitemap.xml redirects to /sitemap-index.xml.
Relative redirects, so the internal port and scheme never leak. The nginx version is hidden.
gzip on (Cloudflare adds brotli at the edge).
Logs go to stdout/stderr; the access log includes cf=$http_cf_connecting_ip for the real
client IP behind Cloudflare.
Built-in HEALTHCHECK on /healthz.
The CSP is rendered at container start. The base policy is 'self' plus what Astro SSG needs
('unsafe-inline' for scripts and styles, data: images, frame-ancestors 'none'), plus
built-in origins for common integrations: Google Tag Manager, Analytics, Ads and reCAPTCHA,
Google Fonts, Meta Pixel, Instagram, TikTok, Microsoft Clarity, Cloudflare Web Analytics and
Turnstile, YouTube/Vimeo/Maps embeds and WhatsApp links. Each *_EXTRA variable takes a
space-separated list of extra origins and defaults to empty.
| Variable | Appended to |
|---|---|
CSP_SCRIPT_EXTRA | script-src |
CSP_STYLE_EXTRA | style-src |
CSP_IMG_EXTRA | img-src |
CSP_FONT_EXTRA | font-src |
CSP_CONNECT_EXTRA | connect-src |
CSP_FRAME_EXTRA | frame-src |
CSP_MEDIA_EXTRA | media-src |
CSP_FORM_ACTION_EXTRA | form-action |
CSP_BUILTIN | Set to off to drop the built-in origins |
DOCROOT_CHECK | Set to skip to disable the index.html check at start |
Only variables prefixed CSP_ are substituted into the nginx config.
| Tag | Meaning |
|---|---|
1.2.0 | Immutable release |
1.2.0-nginx1.30.6 | Same release, with the nginx version spelled out |
1.2 | Latest patch of that minor; recommended |
1 | Latest minor of that major |
1.2.0-YYYYMMDD | Weekly rebuild of the latest release with fresh Alpine security patches |
There is no latest tag; pin at least a minor, or a digest for fully reproducible deploys.
Verify the signature:
cosign verify astrotechdevs/static-sites:1.2 \
--certificate-identity-regexp '^https://github.com/astrotechsh/docker-static-sites/\.github/workflows/release\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
MIT
Content type
Image
Digest
sha256:c3bd6ce7d…
Size
25.4 MB
Last updated
about 9 hours ago
docker pull astrotechdevs/static-sites:1.2.0-20261019