A production‑ready Docker Compose stack that fronts your web apps (e.g., MediaWiki) with Nginx, handles TLS automation with Certbot, serves next‑gen images via imgproxy (AVIF/WebP negotiation with caching & failover), and provides real‑time traffic analytics with GoAccess (WebSocket proxied, behind Basic Auth). It also includes sane log rotation and proxy cache settings.
The stack is designed to run on a single host with Docker, expose multiple virtual hosts, and keep configuration tidy via reusable “includes”.
-
TLS automation (Let’s Encrypt / Certbot)
- Webroot HTTP‑01 challenge via a dedicated
/.well-known/acme-challenge/location. - One‑shot issuance script from a
domains.listfile (one certificate per line). - Safe renewals (no need to stop Nginx) and dry‑run support.
- Ready for OCSP stapling (subject to CA certificate OCSP URL availability).
- Webroot HTTP‑01 challenge via a dedicated
-
Hardened reverse proxy
- HTTP/2, SNI, and strict security headers (HSTS, X‑Content‑Type‑Options, etc.).
- Clean separation via
includes/for security headers, caching, certbot, and upstream maps. - ENV‑driven backend mapping so TEST/PROD switches are minimal.
-
Smart image delivery with imgproxy
- Content negotiation: AVIF → WebP → PNG/JPEG fallback based on
Accept. - Long‑lived, immutable caching with variant‑aware cache keys.
- Failover: if imgproxy is unavailable, Nginx falls back to origin thumbnails.
- SVG passthrough (no accidental raster conversion).
- Content negotiation: AVIF → WebP → PNG/JPEG fallback based on
-
Real‑time analytics with GoAccess
- Uses a single virtual‑host aware access log (per‑vhost analytics).
- Real‑time HTML dashboard via WebSockets proxied at
/ws. - Basic Auth on the stats vhost; WS endpoint whitelisted with an Origin gate.
- Custom bot list and referrer filters to keep “Unique Visitors” sane.
- Persistent DB so historical stats survive container restarts.
-
Logging & rotation
- Unified
access_all.log+ error logs per vhost (optional). - logrotate sidecar with
copytruncatefor zero‑downtime rotation. - Size/time‑based retention with compression.
- Unified
- Docker & Docker Compose
- DNS A/AAAA records pointing to your host
- Ports 80 and 443 reachable from the internet
-
Clone & enter the repository
cd /opt git clone <this-repo-url> nginxproxy cd nginxproxy
-
Copy example files (adjust parameters as needed)
# Environment cp .env.example .env # Nginx vhosts & includes cp -r data/nginx/conf.d/global.conf.example data/nginx/conf.d/global.conf cp -r data/nginx/conf.d/stats.example.com.conf.example data/nginx/conf.d/<stats.example.com.conf> cp -r data/nginx/conf.d/www.example.com.conf.example data/nginx/conf.d/<www.example.com.conf> cp -r data/nginx/conf.d/includes/certbot.conf.example data/nginx/conf.d/includes/certbot.conf cp -r data/nginx/conf.d/includes/security-headers.conf.example data/nginx/conf.d/includes/security-headers.conf cp -r data/nginx/conf.d/includes/site-defaults.conf.example data/nginx/conf.d/includes/site-defaults.conf cp -r data/nginx/conf.d/includes/ssl.conf.example data/nginx/conf.d/includes/ssl.conf # Certbot webroot & config (persisted) mkdir -p data/letsencrypt/{conf,webroot,lib,logs} # GoAccess config & dashboards cp data/goaccess/goaccess.conf.example data/goaccess/conf/goaccess.conf cp data/goaccess/browsers.list.example data/goaccess/conf/browsers.list # Logrotate sidecar cp data/logrotate/nginx-acccess.example data/logrotate/conf/nginx-access
-
Define certificates to issue
-
Edit
issue-from-list.shvariables on top -
Edit
domains.list— one certificate per line, first domain is the cert name:example.com www.example.com stats.example.com
-
-
Bring the stack up
docker compose up -d
-
Issue certificates (staging/dry‑run first)
./issue-from-list.sh domains.list # uses the running certbot container- The script supports
--staging/--dry-runtoggles internally; switch off for production issuance.
- The script supports
-
Visit your sites
- Your app vhosts (e.g.,
https://www.example.com) - Real‑time stats at
https://stats.example.com(behind Basic Auth)
- Your app vhosts (e.g.,
.
├─ docker-compose.yml
├─ .env
├─ geoipupdate.env
├─ data/
│ ├─ nginx/
│ │ └─ conf.d/
│ │ ├─ global.conf
│ │ ├─ <vhosts>.conf
│ │ └─ includes/
│ │ ├─ security.conf
│ │ ├─ certbot.conf
│ │ ├─ cache.conf
│ │ └─ upstreams.map.conf
│ ├─ letsencrypt/
│ │ ├─ conf/ # certs (mounted read-only into nginx)
│ │ └─ webroot/ # HTTP-01 challenge files
│ └─ goaccess/
│ ├─ goaccess.conf
│ └─ browsers.list
├─ issue-from-list.sh
├─ domains.list
├─ nginx_imgproxy_testing.sh
└─ goaccess-referrer-ignore.sh
# includes/certbot.conf
location ^~ /.well-known/acme-challenge/ {
root /srv/certbot/www;
default_type "text/plain";
add_header Cache-Control "no-store";
try_files $uri =404;
auth_basic off;
allow all;
}# Map Accept -> target format
map $http_accept $imgfmt {
"~*image/avif" "avif";
"~*image/webp" "webp";
default "png";
}
# Cache key must vary by format!
proxy_cache_path /var/cache/nginx/img levels=1:2 keys_zone=img_cache:50m inactive=30d max_size=5g;
location ~* ^/images/(?:thumb/)?(.+\.(?:jpe?g|png|gif|webp|avif))$ {
set $src "http://mediawiki$uri$is_args$args"; # or env-driven upstream
# variant-aware cache
proxy_cache img_cache;
proxy_cache_key "$scheme$proxy_host$uri|$imgfmt";
add_header X-Cache $upstream_cache_status always;
# pass to imgproxy
proxy_pass http://imgproxy:8989/insecure/plain/$src@$imgfmt;
# serve stale on trouble & fallback to origin
proxy_cache_use_stale error timeout invalid_header http_500 http_502 http_503 http_504 updating;
proxy_next_upstream error timeout http_500 http_502 http_503 http_504 non_idempotent;
error_page 502 503 504 = @origin_fallback;
proxy_hide_header Vary;
add_header Vary Accept always;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
location @origin_fallback {
proxy_pass http://mediawiki;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}# Protect everything by default
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd.pwd;
# Real-time WS under /ws (no Basic Auth, but origin-gated)
location /ws {
auth_basic off;
if ($http_origin !~* "^https://stats\.example\.com$") { return 403; }
proxy_pass http://goaccess:7890;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}# http {} block
log_format vcombined '$host $remote_addr - $remote_user '
'[$time_local] "$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access_all.log vcombined;This chapter documents every variable in nginxproxy/.env.example and when/how to change it. Copy the file to .env and adjust the values for your setup.
| Variable | Example | Purpose | Notes |
|---|---|---|---|
PROJECT_NAME |
nginxproxy |
Docker Compose project name | Used as container/network prefix. Keep it short and stable. |
| Variable | Example | Purpose | Notes |
|---|---|---|---|
GOACCESS_PORT |
127.0.0.1:7890 |
Bind address for GoAccess WebSocket server | Keep it on 127.0.0.1 and proxy it via NGINX; don’t expose publicly. |
GA_SSL_KEY |
/etc/letsencrypt/live/stats.example.com/privkey.pem |
TLS key used by GoAccess itself | Use certs that match your stats domain. |
GA_SSL_CERT |
/etc/letsencrypt/live/stats.example.com/fullchain.pem |
TLS full chain for GoAccess | Same certificate pair as your stats vhost. |
GA_WS_URL |
wss://stats.example.com/ws |
External WS URL GoAccess advertises to the HTML page | Must be wss://… when served behind HTTPS. |
GA_ORIGIN |
https://stats.example.com |
Allowed browser origin for WebSocket connections | Must match the public URL serving the dashboard. |
Tips
- If you terminate TLS at NGINX only, you can still run GoAccess with
GOACCESS_PORT=127.0.0.1:7890and let the vhost proxy/wsto it. - Ensure your NGINX vhost forwards
UpgradeandConnectionheaders for WebSocket.
| Variable | Example | Purpose | Notes |
|---|---|---|---|
IMGPROXY_BIND |
127.0.0.1:8989 |
Bind address for imgproxy | Keep it on loopback; vhost proxies requests. |
IMGPROXY_ALLOWED_SOURCES |
http://mediawiki:8091,http://127.0.0.1:8091 |
Comma-separated list of allowed source origins | Add all backends that serve your original images (MediaWiki, test env, etc.). |
IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES |
true |
Allow 127.0.0.1/localhost as image sources |
Useful in container-to-container setups. |
IMGPROXY_ENFORCE_WEBP |
false |
Force WebP output for all clients | Usually keep false and negotiate by Accept. |
IMGPROXY_PREFER_WEBP |
false |
Prefer WebP when client supports it | You can negotiate in NGINX; leaving false is fine. |
IMGPROXY_QUALITY |
75 |
Default quality for non-WebP formats | 70–80 is a good balance. |
IMGPROXY_WEBP_QUALITY |
75 |
Quality for WebP output | Can often be a bit lower for similar visual quality. |
IMGPROXY_STRIP_METADATA |
true |
Remove EXIF/ICC/etc. | Saves bytes and avoids leaking camera/location data. |
IMGPROXY_MAX_SRC_RESOLUTION |
50 |
Max source megapixels (width × height ÷ 1e6) | Protects against huge inputs; set 0 to disable. |
IMGPROXY_DOWNLOAD_TIMEOUT |
5 |
Max seconds to fetch source image | Tune if backends are slow. |
IMGPROXY_READ_REQUEST_TIMEOUT |
5 |
Max seconds to read request | Safety limit for slow clients. |
Security recommendations
- Keep imgproxy bound to
127.0.0.1and only reachable via NGINX.
| Variable | Example | Purpose |
|---|---|---|
TZ |
Europe/Berlin |
Container timezone for logs and time-based tasks. |
we will use the apache http docker image and generate the .htpasswd file with that container,
because nginx does not come with a htpasswd binary.
# first time, to create the .htpasswd file
docker run --rm -it \
-v $(pwd)/data/nginx/conf.d/:/work \
httpd:2-alpine \
htpasswd -c /work/.htpasswd <username>
# without -c to append users
docker run --rm -it \
-v $(pwd)/data/nginx/conf.d/:/work \
httpd:2-alpine \
htpasswd /work/.htpasswd <username>
# none interactive, add -c if you like to create a new file
docker run --rm \
-v $(pwd)/data/nginx/conf.d/:/work \
httpd:2-alpine \
htpasswd -b /work/.htpasswd <username> <password>if you like to issue a new certificate you need to setup DNS first. So the Domainname is pointing to the nginx servers
IPv4 or IPv6 address. Then edit or create the domains.list:
-
File with domain lists (one list per line, separate domains with spaces)
-
domains.list is used as the default, or the first parameter.
-
Example:
example.com www.example.com example.org www.example.org blog.example.org
then start the issue script.
chmod +x issue-from-list
./issue-from.list.sh domains.listhere are some comands to check if the renewal process of certbot will work.
# all certs (dryrun only)
docker compose exec certbot certbot renew --dry-run
# Only dry test a specific certificate (dryrun only)
docker compose exec certbot certbot renew --cert-name lhlab.wiki --dry-run
# Force immediate testing (even if it is not yet 30 days before expiry) (dryrun only)
docker compose exec certbot certbot renew --cert-name lhlab.wiki --dry-run --force-renewal
A compact Bash script to verify end‑to‑end image delivery and HTML/CDN caching for the NGINX / MediaWiki stack (NGINX reverse proxy + IMGProxy + MediaWiki). It prints focused headers and clear OK/WARN/FAIL results for each step.
- Image cache warmup (MISS → HIT) with
X-Cachevalidation - Content negotiation via
Accept:(AVIF, WebP, PNG fallback) - Logged‑in/cache‑bypass checks (cookies,
?nocache=1,action=edit, POST) - Validator passthrough (ETag, Last‑Modified) and optional fallback detection
- Optional direct tests against IMGProxy (including 304 revalidation)
The script is self‑contained. Edit the CONFIGURATION block at the top:
HOST,IMG– target host and an existing image pathBASE_URL,PAGE_CACHEABLE,PAGE_START– pages used for HTML/CDN checksNEGATE_QS_FOR_NEG– use image URL without query for negotiation tests (default:1)EXPECT_FALLBACK– set1only when you intentionally stop IMGProxy to verify fallbackIMGPROXY_LOCAL,MW_BACKEND_IMAGEURL– enable optional direct IMGProxy checks
Requirements:
bash,curl
chmod +x ./nginx_imgproxy_testing.sh
./nginx_imgproxy_testing.shThe script prints the relevant response headers and summarizes results at the end. A non‑zero exit code indicates at least one FAIL.