Reverse Proxy
Expose Penombre behind a reverse proxy with HTTPS.
Penombre listens on port 3000 by default. In production you should place it behind a reverse proxy to handle HTTPS termination and forward traffic to the container.
Warning — set the
ORIGINenvironment variable to your public URL (e.g.https://cloud.example.com). This is required for OAuth callbacks, email links, and passkey registration to work correctly.
#Requirements
Your reverse proxy must:
- Forward the
Host,X-Real-IP,X-Forwarded-For, andX-Forwarded-Protoheaders so Penombre can resolve client addresses and detect HTTPS. - Allow large request bodies — Penombre sets
BODY_SIZE_LIMIT=Infinityfor file uploads, so limit enforcement should happen at the proxy level if needed. - Not buffer responses — streaming is used for file downloads.
- Pass WebDAV methods (
PROPFIND,PROPPATCH,MKCOL,MOVE,LOCK,UNLOCK) through untouched. Caddy, Nginx'sproxy_passand Traefik do by default; a WAF or a CDN in front may not.
#Client address {#client-address}
Forwarding the X-Forwarded-For header is not enough on its own: Penombre only
trusts a header you explicitly name, or it falls back to the TCP connection's
own address, which behind a proxy is the proxy's address for every request. Set
ADDRESS_HEADER=x-forwarded-for (and XFF_DEPTH, the number of trusted proxy
hops to count back from the end of that header, 1 by default) so Penombre
reads the real client address instead.
This is not cosmetic. The client address is what per-IP rate limiting keys on, sign-in lookups, share-link unlock attempts, so without it every request behind the proxy shares one bucket, and thirty attempts from anywhere lock out every visitor at once. The invalid API key lockout keys on it too.
Only set ADDRESS_HEADER when every request actually goes through a proxy you
control: a client can set X-Forwarded-For itself, and a value trusted with no
proxy in front to overwrite it lets that client claim any address it likes.
#Caddy
Caddy handles HTTPS automatically via Let's Encrypt.
# Caddyfile
cloud.example.com {
reverse_proxy localhost:3000
}That's it — Caddy provisions and renews TLS certificates automatically.
#Nginx
# /etc/nginx/sites-available/penombre
server {
listen 80;
server_name cloud.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name cloud.example.com;
ssl_certificate /etc/letsencrypt/live/cloud.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cloud.example.com/privkey.pem;
client_max_body_size 0; # No upload size limit
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
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;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}Use Certbot to provision certificates:
sudo certbot --nginx -d cloud.example.com#Traefik
Add labels to the app service in your compose.yaml:
services:
app:
image: orochibraru/penombre:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.penombre.rule=Host(`cloud.example.com`)"
- "traefik.http.routers.penombre.entrypoints=websecure"
- "traefik.http.routers.penombre.tls.certresolver=letsencrypt"
- "traefik.http.services.penombre.loadbalancer.server.port=3000"
# Remove the ports mapping — Traefik handles routing
# ports:
# - 3000:3000Make sure Traefik is configured with an ACME certificate resolver named
letsencrypt and an entrypoint named websecure on port 443.
#Docker Compose with a proxy network
When running both your reverse proxy and Penombre in Docker, place them on a shared network so the proxy can reach the app container by service name:
services:
app:
image: orochibraru/penombre:latest
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
- storage_data:/data
env_file: .env
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/penombre
networks:
- default
- proxy
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD-postgres}
- POSTGRES_DB=${POSTGRES_DB-penombre}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test:
[
CMD-SHELL,
"sh -c 'pg_isready -U ${POSTGRES_USER-postgres} -d
${POSTGRES_DB-penombre}'",
]
interval: 1s
timeout: 2s
retries: 10
start_period: 3s
volumes:
postgres_data:
storage_data:
networks:
proxy:
external: trueWith this setup, remove the ports mapping from the app service — the reverse
proxy accesses it via http://app:3000 on the shared proxy network. The
database port is also no longer exposed to the host.
This guide lives in the project repo: edit it there, and this page follows within a day.