Sign inSign up

astrotechdevs/static-sites

By astrotechdevs

•Updated about 9 hours ago

Hardened unprivileged nginx for static sites (Astro SSG) behind Cloudflare and nginx-proxy

Image
Security
Web servers
0

885

astrotechdevs/static-sites repository overview

⁠static-sites

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.

  • Multi-arch: linux/amd64, linux/arm64
  • Base: nginxinc/nginx-unprivileged (alpine), pinned by version and digest
  • Runs as uid 101, listens on 8080, works with read_only: true
  • Every release passes a smoke test on both architectures and a Trivy scan (no fixable HIGH/CRITICAL), ships with provenance and SBOM attestations, and is signed with cosign (keyless)

⁠Quick start

docker 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.

⁠Docker Compose (behind nginx-proxy)

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.

⁠What the image does

  • 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:

    PathCache-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 videopublic, max-age=86400, must-revalidate
    /.well-known/*public, max-age=3600
    HTML and everything elsepublic, max-age=0, must-revalidate
    /healthzno-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.

⁠Environment variables

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.

VariableAppended to
CSP_SCRIPT_EXTRAscript-src
CSP_STYLE_EXTRAstyle-src
CSP_IMG_EXTRAimg-src
CSP_FONT_EXTRAfont-src
CSP_CONNECT_EXTRAconnect-src
CSP_FRAME_EXTRAframe-src
CSP_MEDIA_EXTRAmedia-src
CSP_FORM_ACTION_EXTRAform-action
CSP_BUILTINSet to off to drop the built-in origins
DOCROOT_CHECKSet to skip to disable the index.html check at start

Only variables prefixed CSP_ are substituted into the nginx config.

⁠Tags

TagMeaning
1.2.0Immutable release
1.2.0-nginx1.30.6Same release, with the nginx version spelled out
1.2Latest patch of that minor; recommended
1Latest minor of that major
1.2.0-YYYYMMDDWeekly 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

⁠License

MIT

Tag summary

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