nginx Docker config to serve a public S3/MinIO Angular single-page-application over HTTP

This is my nginx config which automatically serves a public S3/MinIO Angular single-page-application over HTTP. It is expected to be used in a configuration with an external proxy providing HTTPS. In my case, this is Traefik. See Simple Traefik docker-compose setup with Lets Encrypt Cloudflare DNS-01 & TLS-ALPN-01 & HTTP-01 challenges for details on my configuration.

This config variant does not include config for service @angular/localize configurations over S3. See How to use nginx to deliver Multi-Language I18N Angular UIs using @angular/localize for a nginx example serving a static site with multiple languages and automatic selection logic.

nginx.conf

You typically only need to change the following variables here:

    # Change this to your S3 bucket name. Make sure that is is set to public!
    set $bucket "/my-bucket";
    # Change this to your MinIO/S3 host (no scheme)
    set $minio_host "minio.mydomain.com";

Full config:

# /etc/nginx/nginx.conf
worker_processes auto;

events { worker_connections 1024; }

http {
  include       mime.types;
  default_type  application/octet-stream;
  sendfile      on;

  # DNS via Docker host (Docker embedded DNS)
  resolver 127.0.0.11 valid=24h ipv6=off;
  resolver_timeout 5s;

  # Optional gzip for text assets
  gzip on;
  gzip_vary on;
  gzip_min_length 1024;
  gzip_proxied any;
  gzip_types
      text/plain text/css application/javascript application/json
      application/xml application/rss+xml image/svg+xml;

  # Disk cache (tune size as needed)
  proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:100m max_size=10g inactive=30d use_temp_path=off;

  server {
    listen 80;
    server_name _;  # Traefik routes here

    # Convenience var for your bucket path
    # Convenience vars for your bucket path and MinIO host
    set $bucket "/my-bucket";
    # Change this to your MinIO/S3 host (no scheme)
    set $minio_host "minio.techoverflow.net";

    # Intercept upstream 404s/403s and route to SPA
    error_page 404 = @spa;
    error_page 403 = @spa;

    # Exact root "/" -> serve index.html
    location = / {
    proxy_pass https://$minio_host$bucket/index.html;
    proxy_set_header Host $minio_host;
      proxy_ssl_server_name on;

      proxy_buffering on;
      proxy_cache STATIC;
      proxy_cache_key $scheme$proxy_host$uri$is_args$args;

      # Do not cache SPA HTML in browsers (optional)
      add_header Cache-Control "no-cache, must-revalidate" always;

      # Intercept errors so error_page works even from cache layer
      proxy_intercept_errors on;

      # Avoid caching upstream errors
      proxy_no_cache $upstream_status = 404;
      proxy_cache_bypass $upstream_status = 404;
      proxy_no_cache $upstream_status = 403;
      proxy_cache_bypass $upstream_status = 403;

      add_header X-Cache-Status $upstream_cache_status always;
    }

    # Unified handling for everything else
    location / {
    proxy_pass https://$minio_host$bucket$uri$is_args$args;
    proxy_set_header Host $minio_host;
      proxy_ssl_server_name on;

      proxy_buffering on;
      proxy_http_version 1.1;

      proxy_cache STATIC;
      proxy_cache_key $scheme$proxy_host$uri$is_args$args;

      # Unified TTLs for successful responses
      proxy_cache_valid 200 301 302 24h;
      # Do not cache upstream errors (prevents serving plain NGINX 404)
      proxy_no_cache $upstream_status = 404;
      proxy_cache_bypass $upstream_status = 404;
      proxy_no_cache $upstream_status = 403;
      proxy_cache_bypass $upstream_status = 403;

      # Optional browser caching (enable if you version assets)
      # add_header Cache-Control "public, max-age=86400, immutable" always;

      add_header X-Cache-Status $upstream_cache_status always;

      # Make sure SPA fallback triggers on 404/403 from origin
      proxy_intercept_errors on;
    }

    # SPA fallback target
    location @spa {
      proxy_pass https://$minio_host$bucket/index.html;
      proxy_set_header Host $minio_host;
      proxy_ssl_server_name on;

      proxy_buffering on;
      proxy_cache STATIC;
      proxy_cache_key $scheme$proxy_host$uri$is_args$args;

      add_header Cache-Control "no-cache, must-revalidate" always;
      add_header X-Cache-Status $upstream_cache_status always;
    }
  }
}

docker-compose.yml

The docker-compose config is pretty straightforward.

services:
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx_cache:/var/cache/nginx
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app-mydomain.rule=Host(`app.mydomain.com`)"
      - "traefik.http.routers.app-mydomain.entrypoints=websecure"
      - "traefik.http.routers.app-mydomain.tls.certresolver=cloudflare"
      - "traefik.http.routers.app-mydomain.tls.domains[0].main=mydomain.com"
      - "traefik.http.routers.app-mydomain.tls.domains[0].sans=*.mydomain.com"