Sign inSign up

rathinosk/nginx-static

By rathinosk

•Updated about 5 hours ago

Small, hardened nginx image for hosting static sites (Astro, Hugo, plain HTML) from /app.

Image
Web servers
0

178

rathinosk/nginx-static repository overview

⁠nginx-static

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.

  • Base: nginx:1.30-alpine (stable)
  • Runs as: nginx (UID/GID 101) by default; any non-root UID works
  • Port: 8080
  • Document root: /app (shows a placeholder page until you mount a site)
  • Health check: GET /healthz returns 200 ok
  • Footprint: a few MB of RAM with one worker; 64 MB is a comfortable limit

Source and issues: github.com/rathinosk/nginx-static⁠

⁠Tags

TagMeaning
1.30nginx 1.30. Pin production to a version tag.
latestThe most recent build from main.

A new nginx release gets a new version tag, so pinned deployments only change when you update them.

⁠Quick Start

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.

⁠Docker Compose

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

⁠Volumes

PathPurposeMode
/appYour static siteread-only
/etc/nginx/site.dOptional *.conf files included in the server blockread-only
/logsaccess.log and error.logwritable by the container's UID

⁠Environment Variables

VariableDefaultPurpose
LOG_RETENTION_DAYS90Days to keep compressed rotated logs

⁠What It Does

⁠Serving
  • Clean URLs: /about serves about.html or about/index.html.
  • Uses your site's 404.html as the error page, if it has one.
  • Relative redirects (absolute_redirect off), so /dir → /dir/ is correct behind a proxy.
  • gzip for text types, plus gzip_static for pre-compressed .gz files.
  • Real client IPs from X-Forwarded-For (private proxy ranges trusted).
⁠Caching

Cache-Control is set by path:

PathValue
/_astro/* (hashed build assets)public, max-age=31536000, immutable
*.css, *.js, *.mjspublic, max-age=604800 (7 days)
Images, fonts, media, .pdf, .zippublic, max-age=2592000 (30 days)
Everything else, including HTMLno-cache
⁠Security Headers

Sent on every response, including errors:

  • Strict-Transport-Security: max-age=31536000
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: SAMEORIGIN
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
  • Cross-Origin-Resource-Policy: same-site

A Content-Security-Policy is site-specific, so add your own in site.d.

⁠Blocking
  • Dotfiles (.git, .env, .htaccess, …) return 404; /.well-known/ is still served for ACME and security.txt.
  • Server-side and backup files (.php, .asp, .jsp, .cgi, .env, .ini, .log, .bak, .sql, .conf, .sh, .swp) return 404, even if one ends up in /app.
  • Common CMS probes (/wp-admin, /wp-login, /phpmyadmin, /xmlrpc.php, …) return 404 and aren't logged.
  • Known scanner user agents (nikto, sqlmap, dirbuster, w3af, censys, python-requests) get 403.

⁠Per-site Configuration

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
    

⁠Logging

  • Written to /logs/access.log and /logs/error.log.
  • Rotated daily at midnight UTC to access.log-YYYYMMDD.gz.
  • Compressed logs older than 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.

⁠Running as Another User

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.

⁠License

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.

Tag summary

Content type

Image

Digest

sha256:c397792ad…

Size

24.9 MB

Last updated

about 5 hours ago

docker pull rathinosk/nginx-static