Reverse Proxy Setup¶
Validibot does not include a reverse proxy in the default Docker Compose configuration. You'll need to set up your own reverse proxy to handle TLS termination and route traffic to the application.
This follows the same approach as other self-hosted Docker Compose projects like Sentry, PostHog, Paperless-ngx, and Immich.
Why no built-in proxy?¶
Most self-hosters already have a reverse proxy in their infrastructure (Traefik for other services, nginx on the host, Cloudflare Tunnel, etc.). Including one by default would conflict with existing setups and add complexity for users who don't need it.
Before you start¶
Make sure Validibot is running and accessible on its default port:
You should see a successful health check response before configuring the proxy.
Configuration requirements¶
Whichever proxy you choose, ensure:
- TLS termination — The proxy handles HTTPS; Validibot receives plain HTTP
- Proper headers — Forward
X-Forwarded-For,X-Forwarded-Proto, andHost - WebSocket support — Required for real-time updates (if using HTMx WebSocket extensions)
- Timeout settings — Increase timeouts for long-running validation uploads
- Trusted-proxy count — Set
DRF_NUM_PROXIESto how many proxies sit in front of Validibot (see below). Getting it wrong weakens IP-based rate limiting.
Update your Validibot environment variables:
# .envs/.production/.self-hosted/.django
DJANGO_ALLOWED_HOSTS=validibot.example.com
SITE_URL=https://validibot.example.com
DJANGO_SECURE_SSL_REDIRECT=False # Proxy handles TLS
DRF_NUM_PROXIES=1 # Trusted proxies in front of Validibot (see below)
Trusted-proxy count and throttle integrity¶
Validibot's rate limits — including the anonymous agent endpoints' pre-payment
abuse guards — key on the client IP. Behind a proxy, Django never sees the client
directly: the real IP arrives in X-Forwarded-For, a comma-separated list that
each proxy appends to. A client can forge the left of that list, so DRF reads
the real IP from the right, using DRF_NUM_PROXIES to know how many
right-hand entries were added by infrastructure you trust.
Set it to the number of proxies that append to X-Forwarded-For between the
internet and Validibot:
- One reverse proxy (the typical nginx / Caddy / Traefik setup above):
1(the default) - Two proxies (e.g. a CDN or load balancer in front of your reverse proxy):
2 - No proxy at all (Validibot exposed directly):
0— ignoreX-Forwarded-For
Too low and distinct clients collapse onto a shared proxy IP and throttle each
other; too high and a client can spoof X-Forwarded-For to dodge the throttle
entirely. Verify it against a real request instead of guessing:
# From a machine whose public IP you know (check: curl https://ifconfig.me):
curl https://validibot.example.com/api/v1/auth/debug/
# Count from the RIGHT of the returned X-Forwarded-For until you reach your real
# IP — that position is DRF_NUM_PROXIES. Then confirm a spoof is ignored:
curl -H "X-Forwarded-For: 9.9.9.9" https://validibot.example.com/api/v1/auth/debug/
# 9.9.9.9 must land to the LEFT of the entry your count selects.
The hosted cloud deployment (app.validibot.com) sets this to
2because it runs behind a Google external HTTPS load balancer.
Stronger: overwrite X-Forwarded-For at your trusted edge¶
Counting hops is correct but fragile — it breaks silently if your proxy chain changes. The more robust pattern is to have your outermost proxy replace the header with the client IP it actually observed, so forged upstream values never reach Validibot:
- nginx: at the internet-facing proxy, use
proxy_set_header X-Forwarded-For $remote_addr;(overwrite) instead of$proxy_add_x_forwarded_for(append), then setDRF_NUM_PROXIES=1. - Google Cloud external HTTPS LB: rewrite the header on the backend service
so only LB-verified values survive, then keep
DRF_NUM_PROXIES=2:
gcloud compute backend-services update BACKEND_SERVICE_NAME --global \
--custom-request-header='X-Forwarded-For:{client_ip_address},{server_ip_address}'
{client_ip_address} is the IP the LB itself saw, not client-supplied, so this
strips any spoofed prefix at the edge.
Do both where you can: the edge rewrite removes the spoofing surface, and
DRF_NUM_PROXIES keeps DRF reading the right position (and covers deploys where
you can't rewrite at the edge). Google's
external Application Load Balancer docs
describe the header behaviour and the {client_ip_address} variable in full.
Caddy¶
Caddy automatically obtains and renews TLS certificates via Let's Encrypt. It's the simplest option for most deployments.
Bundled with the production stack (recommended)¶
The docker-compose.production.yml file ships with an optional Caddy
service behind a Compose profile, so the simplest path is to just turn
it on. Caddy reads its Caddyfile from deploy/self-hosted/caddy/Caddyfile
and will auto-issue a Let's Encrypt certificate for the hostname
declared there. The hostname must already resolve to this host's
public IP — check first with just self-hosted check-dns.
# One-time, before deploy: edit the Caddyfile and replace the placeholder
# hostname with your real one.
# Then bring the stack up with the caddy profile activated.
COMPOSE_PROFILES=caddy just self-hosted deploy
This is off by default because most operators already run a reverse proxy. Use one of the alternative recipes below if you do.
Standalone (on host)¶
Install Caddy on your host system:
# Debian/Ubuntu
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
Create /etc/caddy/Caddyfile:
Start Caddy:
That's it. Caddy will automatically obtain a certificate and start serving HTTPS.
Docker container¶
Add Caddy as a service alongside Validibot. Create compose/production/caddy/Caddyfile:
Create docker-compose.caddy.yml:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./compose/production/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
- DOMAIN=${VALIDIBOT_DOMAIN:-localhost}
networks:
- validibot
volumes:
caddy_data:
caddy_config:
networks:
validibot:
external: true
name: validibot_validibot
Run both compose files:
# Start Validibot
just self-hosted bootstrap
# Start Caddy (after the validibot network exists)
VALIDIBOT_DOMAIN=validibot.example.com docker compose -f docker-compose.caddy.yml up -d
Important: The
caddy_datavolume stores certificates. Without persistence, Caddy requests new certificates on every restart, which can hit Let's Encrypt rate limits.
Traefik¶
Traefik integrates natively with Docker and discovers services via labels. It's a good choice if you're already using Traefik for other services.
Docker labels approach¶
Create a separate docker-compose.traefik.yml and run it alongside the
main stack:
services:
traefik:
image: traefik:v3.0
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_letsencrypt:/letsencrypt
networks:
- validibot
volumes:
traefik_letsencrypt:
networks:
validibot:
external: true
name: validibot_validibot
Add labels to the web service in docker-compose.production.yml:
services:
web:
labels:
- "traefik.enable=true"
- "traefik.http.routers.validibot.rule=Host(`validibot.example.com`)"
- "traefik.http.routers.validibot.entrypoints=websecure"
- "traefik.http.routers.validibot.tls.certresolver=letsencrypt"
- "traefik.http.services.validibot.loadbalancer.server.port=8000"
# Remove or comment out the ports mapping
# ports:
# - "8000:8000"
nginx¶
nginx is the most widely deployed reverse proxy. Use it if you're already familiar with nginx or need advanced configuration options.
Standalone (on host)¶
Install nginx:
Create /etc/nginx/sites-available/validibot:
server {
listen 80;
server_name validibot.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name validibot.example.com;
# Certificates (use certbot or your own)
ssl_certificate /etc/letsencrypt/live/validibot.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/validibot.example.com/privkey.pem;
# SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Proxy settings
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Increase timeouts for large file uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 100M;
}
# WebSocket support (if needed)
location /ws/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
Enable the site and obtain certificates:
sudo ln -s /etc/nginx/sites-available/validibot /etc/nginx/sites-enabled/
sudo certbot --nginx -d validibot.example.com
sudo systemctl reload nginx
Docker container¶
Create docker-compose.nginx.yml:
services:
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./compose/production/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./compose/production/nginx/certs:/etc/nginx/certs:ro
networks:
- validibot
networks:
validibot:
external: true
name: validibot_validibot
You'll need to manage certificates separately (e.g., with certbot on the host, or a sidecar container).
Cloudflare Tunnel¶
Cloudflare Tunnel is ideal for home servers or environments where you can't open ports. Traffic routes through Cloudflare's network, so you get DDoS protection and don't need to expose your server directly.
- Create a tunnel in the Cloudflare Zero Trust dashboard
- Install
cloudflaredon your host or run it as a container - Configure the tunnel to route to
http://localhost:8000
# docker-compose.cloudflared.yml
services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
networks:
- validibot
networks:
validibot:
external: true
name: validibot_validibot
Verifying the setup¶
After configuring your proxy:
-
Check HTTPS access:
-
Verify headers are forwarded:
-
Test file uploads: Submit a test validation to ensure large files work with your timeout settings.
Troubleshooting¶
CSRF verification failed¶
If you see "CSRF verification failed" errors after adding a proxy:
- Ensure
SITE_URLmatches your public domain exactly - Check that
X-Forwarded-Proto: httpsis being sent - Verify
DJANGO_ALLOWED_HOSTSincludes your domain
502 Bad Gateway¶
The proxy can't reach Validibot:
- Check Validibot is running:
docker compose ps - Verify the network connection:
docker network inspect validibot_validibot - Ensure the proxy is targeting the correct service name and port (
web:8000)
Certificate errors¶
- Caddy: Check the
caddy_datavolume persists between restarts - Traefik: Verify your ACME email is set and DNS points to your server
- nginx: Run
certbot renew --dry-runto test renewal