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"