Reading time: ~12 minutes
Audience: Self-hosters running Immich behind a reverse proxy
Why Immich Needs a Reverse Proxy
Immich runs on port 2283 by default. A reverse proxy provides:
- SSL termination (HTTPS)
- Domain-based routing (photos.mydomain.com)
- Rate limiting (protect uploads)
- Security headers (HSTS, CSP, X-Frame-Options)
- Load balancing (if running multiple instances)
| Feature | Without Proxy | With Proxy |
|---|---|---|
| HTTPS | Manual cert management | Automatic (Let’s Encrypt) |
| Domain | IP:2283 | photos.mydomain.com |
| Rate limiting | None | Configurable |
| Security headers | None | Full HSTS/CSP |
| Path-based routing | N/A | /api, /share, etc. |
Option 1: Nginx Reverse Proxy
Nginx Config (HTTP → HTTPS redirect)
server {
listen 80;
server_name photos.mydomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name photos.mydomain.com;
# SSL certificates (Let's Encrypt via certbot)
ssl_certificate /etc/letsencrypt/live/mydomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=immich:10m rate=10r/s;
limit_req zone=immich burst=20 nodelay;
# Client max body size for uploads
client_max_body_size 50000M;
location / {
proxy_pass http://immich-server:2283;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
Docker Compose (Nginx + Immich)
version: "3.8"
services:
immich-server:
image: ghcr.io/immich-app/immich-server:release
container_name: immich_server
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
ports:
- 2283:3001
restart: always
immich-machine-learning:
image: ghcr.io/immich-app/immich-machine-learning:release
container_name: immich_machine_learning
volumes:
- model-cache:/cache
env_file:
- .env
restart: always
nginx:
image: nginx:alpine
container_name: immich_nginx
ports:
- 80:80
- 443:443
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- immich-server
restart: always
redis:
image: redis:6-alpine
container_name: immich_redis
restart: always
database:
image: postgres:14-alpine
container_name: immich_postgres
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
restart: always
volumes:
model-cache:
pgdata:
Option 2: Traefik (Docker-native)
Traefik Docker Compose
version: "3.8"
services:
traefik:
image: traefik:v2.10
container_name: traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@mydomain.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
restart: always
immich-server:
image: ghcr.io/immich-app/immich-server:release
container_name: immich_server
labels:
- "traefik.enable=true"
- "traefik.http.routers.immich.rule=Host(`photos.mydomain.com`)"
- "traefik.http.routers.immich.entrypoints=websecure"
- "traefik.http.routers.immich.tls.certresolver=letsencrypt"
- "traefik.http.services.immich.loadbalancer.server.port=3001"
- "traefik.http.middlewares.immich-headers.headers.customFrameOptionsValue=SAMEORIGIN"
- "traefik.http.middlewares.immich-headers.headers.contentTypeNosniff=true"
- "traefik.http.routers.immich.middlewares=immich-headers"
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
restart: always
immich-machine-learning:
image: ghcr.io/immich-app/immich-machine-learning:release
container_name: immich_machine_learning
volumes:
- model-cache:/cache
env_file:
- .env
restart: always
redis:
image: redis:6-alpine
container_name: immich_redis
restart: always
database:
image: postgres:14-alpine
container_name: immich_postgres
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
restart: always
volumes:
model-cache:
pgdata:
Traefik Middleware for Rate Limiting
labels:
- "traefik.http.middlewares.immich-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.immich-ratelimit.ratelimit.burst=200"
- "traefik.http.routers.immich.middlewares=immich-headers,immich-ratelimit"
Option 3: Caddy (Simplest Config)
Caddyfile
photos.mydomain.com {
reverse_proxy immich-server:3001 {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
}
tls admin@mydomain.com
# Security headers
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
}
# Rate limiting (requires caddy-ratelimit plugin)
rate_limit {
zone static {
key static
events 100
window 1m
}
}
# Upload size
request_body {
max_size 50GB
}
}
Docker Compose (Caddy)
services:
caddy:
image: caddy:2-alpine
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: always
immich-server:
image: ghcr.io/immich-app/immich-server:release
container_name: immich_server
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
restart: always
# ... (redis, postgres, ML same as above)
volumes:
caddy_data:
caddy_config:
Performance Tuning
Nginx Buffer Sizes
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
proxy_temp_file_write_size 256k;
Immich .env for Large Libraries
# .env
UPLOAD_LOCATION=/mnt/photos
DB_HOSTNAME=immich_postgres
DB_USERNAME=immich
DB_PASSWORD=your-secure-password
DB_DATABASE_NAME=immich
REDIS_HOSTNAME=immich_redis
# Increase for large libraries
MACHINE_LEARNING_WORKERS=2
MACHINE_LEARNING_WORKER_TIMEOUT=120
Common Mistakes
Mistake 1: Missing WebSocket Support
Immich uses WebSockets for live updates. Ensure your proxy passes Upgrade and Connection headers.
Mistake 2: Too Small client_max_body_size
Default Nginx limit is 1MB. Immich uploads need 50GB+ for video. Set:
client_max_body_size 50000M;
Mistake 3: Forgetting X-Forwarded-Proto
Immich needs this header to generate correct URLs. All configs above include it.
Conclusion
Summary
Any reverse proxy works with Immich. Caddy is simplest for beginners, Traefik is best for Docker-native stacks, and Nginx offers maximum control for advanced users.
Next Steps
- Choose your proxy (Caddy for speed, Nginx for control)
- Set DNS A record to your server IP
- Deploy the Docker Compose stack
- Test with
curl -I https://photos.mydomain.com
Affiliate Opportunities
- Domain registrars: Namecheap, Cloudflare Registrar
- VPS/cloud: Hetzner, DigitalOcean, Linode
- Hardware: Mini PCs for home hosting (Minisforum, Beelink)
- Storage: NAS drives, USB enclosures
Internal Linking
immich-setup→immich-photo-server.mddocker-compose→docker-compose-yml-examples.mdtraefik→traefik-docker-setup.mdssl→lets-encrypt-homelab.md
CTA
- Which reverse proxy do you use? Nginx, Traefik, or Caddy? Vote in the comments.
- Subscribe for more self-hosted app deployment guides.