Deployment
Running behind a TLS proxy or a load balancer, and running several instances.
Web servers has tested configurations for nginx, HAProxy, Apache httpd, Caddy and Traefik.
HTTP to HTTPS
With server.https_redirect (RP_SERVER_HTTPS_REDIRECT), plain HTTP requests are redirected to the same host, path and query over HTTPS before any rule: 301 for GET and HEAD, 308 for other methods so that they are repeated as they were. Requests that a TLS proxy forwards with X-Forwarded-Proto: https (or wss, as Traefik marks WebSocket upgrades) go to the rules as usual. With access_log.trusted_proxies set, only the header of a trusted proxy counts; without, any peer's does, and a client sending that header itself only skips the upgrade. Rules cannot do this themselves: the server cannot tell the schemes apart, so a rule to the same host over HTTPS is refused as a redirect loop.
Redirect loops behind a containerised proxy
List the address the TLS proxy connects from in access_log.trusted_proxies. A proxy in another container or pod connects from that container's address: with only 127.0.0.1/32 listed, its X-Forwarded-Proto: https is ignored, HTTPS requests are redirected to HTTPS again, and browsers loop. List the network's CIDR (the Compose network's subnet, the cluster's pod CIDR) instead. Unlisted, the proxy is also held to server.max_connections_per_ip connections (256) as one client address.
In front of a website
With server.upstream (RP_SERVER_UPSTREAM, for example http://127.0.0.1:3000), redirect-proxy sits in front of a website: TLS terminator or load balancer, then redirect-proxy, then the site. It answers the requests its rules match (and server.health_path, server.metrics_path, server.https_redirect, 414s) itself, and forwards every other request to the upstream, whatever its method, instead of answering 404 (server.fallback_url cannot be set as well). Web servers has the web server's side.
- The request goes as it came (method, path and query,
Host, headers, body streamed), over HTTP/1.1, without the hop-by-hop headers (Connectionand the headers it names,Keep-Alive,TE,Trailer,Transfer-Encoding,Upgrade,Proxy-Connection).Expect: 100-continueis answered by redirect-proxy.Content-Lengthis sent once, normalised; chunked bodies are re-chunked. A request with bothContent-LengthandTransfer-Encoding, an invalid length, a transfer coding other thanchunked, aTransfer-Encodingin an HTTP/1.0 request, or malformed chunks gets 400 and its connection is closed. HTTP/1.0 requests do not upgrade. X-Forwarded-Forgets the client address. From a peer inaccess_log.trusted_proxies, it is appended to the incoming list, and the incomingX-Forwarded-ProtoandX-Forwarded-Hostare kept, as areForwarded,X-Real-IP,X-Forwarded-Port,X-Forwarded-Prefix,X-Forwarded-ServerandX-Forwarded-Ssl. From any other peer the first three are replaced (httpand theHost; noX-Forwarded-Hostfor an HTTP/1.0 request withoutHost) and the others dropped, withX-Client-IPandTrue-Client-IP, so that the upstream cannot be handed a forged address or host. Whenauth.headeris enabled, its user, email and role headers are dropped too from peers outsideauth.header.trusted_proxies(listing a proxy inaccess_log.trusted_proxiesdoes not vouch for the users it forwards), so that the upstream cannot be handed a forged identity (withauth.headerdisabled, they are passed on: an authenticating proxy may set them). A header spelled with_instead of-(X_Forwarded_For), which some applications read as the same one, is dropped whenever its-spelling is set or dropped.- The response is relayed as it comes, streamed, without hop-by-hop headers (a
Connectionheader cannot remove its framing). Transfer codings other thanchunkedare kept; an HTTP/1.0 client cannot get them (502). A response head with control characters gets 502. Each client connection opens one upstream connection when it first needs one and keeps it while both sides keep alive. - The request body is streamed to the upstream before its response is read: an upstream that answers early (a 413, say) is relayed once the body is sent, or when it stops reading. Each read of the client's body may wait 10 seconds (an idle timeout, like nginx's
client_body_timeout, not a limit on the whole upload); then 408.server.max_request_body_byteslimits the body's size (413). - WebSockets and other upgrades (
Connection: upgrade) are tunnelled once the upstream answers 101 with the protocol asked for (a 101 to another protocol gets 502), until either side closes or no byte flows forserver.upstream_timeout: have the application ping more often than that. A tunnel counts as one upstream request; its lifetime is the duration of its access log line, but is not inredirect_proxy_upstream_duration_seconds. Upgrade: h2cis never forwarded: the request goes on as plain HTTP/1.1, so that HTTP/2 cannot bypass the forwarding headers.- An upstream that cannot be reached gets the client a 502, one that does not send its response head within
server.upstream_timeout(default 60 seconds) a 504, one that closes the connection before a complete head a 502. These are logged as warnings, at most one a second, and counted inredirect_proxy_upstream_errors_total. - An
https://upstream's certificate is checked against the system CAs;server.upstream_tls_verify = falseaccepts a self-signed one. - Forwarded requests are logged with the upstream's status, the body bytes relayed and the full duration, without a rule id; 499 when the client went away before the whole response was sent. Their time is in
redirect_proxy_upstream_duration_seconds, not in the redirect listener'sredirect_proxy_request_duration_seconds.
Several instances
Each instance keeps some state in memory: admin sessions, login and token throttling, and the access tokens of the built-in token endpoint. Hit counts and the audit log are kept in its own data directory. Instances do not share any of it, so run one writer and any number of replicas:
- The writer runs the admin and the API, and owns a writable data directory. Run one: two admins writing the same file would overwrite each other's changes, and a session only works on the instance that created it.
- Replicas only redirect:
RP_ADMIN_ENABLED=falseandRP_API_ENABLED=false. They readredirects.jsonfrom a volume that can be read-only (a Kubernetes ConfigMap, a directory synced from git, the writer's volume mounted read-only), withstorage.watch_interval(RP_STORAGE_WATCH_INTERVAL) to pick up changes. They write nothing there. Their hit counts are then not kept across restarts, and they log that once. Setserver.metrics_pathto scrape their metrics (see Metrics on the traffic port). Hit counts are per instance: the writer's admin shows only its own, so judge unused rules from the replicas that take the traffic. - The file must carry rule ids, since a replica cannot write the ids it would assign. Use the writer's file or
GET /api/redirects/export, or give a hand-written file its ids withredirect-proxy rules importinto a data directory. - Replace the file atomically (write a temporary file, then rename it, as ConfigMap updates and
gitcheckouts do), so that a replica never reads half a file.
Header authentication
Trusted proxy headers trust auth.header.trusted_proxies, by default 127.0.0.1/32 and ::1/128: every process on the host, including a website behind server.upstream and redirect-proxy's own forwarded requests if server.upstream points at the admin port. Narrow it to the address the authenticating proxy connects from (its container or pod address, or a dedicated network), and keep the admin listener off every other network.
Kubernetes
One writer and replicas, as in Several instances. The writer keeps its data directory on a volume and serves the admin and the API inside the cluster; the replicas serve the traffic from a ConfigMap, with a read-only root file system.
The writer, its volume and an internal service for the admin and the API (port 8081). It also needs a redirect-proxy Secret with the bootstrap password (kubectl create secret generic redirect-proxy --from-literal=admin-password=…):
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redirect-proxy-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redirect-proxy-writer
spec:
replicas: 1
# never two writers on the volume, even during a rollout
strategy:
type: Recreate
selector:
matchLabels:
app: redirect-proxy
role: writer
template:
metadata:
labels:
app: redirect-proxy
role: writer
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
containers:
- name: redirect-proxy
image: ghcr.io/janalis/redirect-proxy:latest
env:
- name: RP_ADMIN_PUBLIC_URL
value: https://redirects-admin.example.com
- name: RP_BOOTSTRAP_ADMIN_USERNAME
value: admin
- name: RP_BOOTSTRAP_ADMIN_PASSWORD_FILE
value: /run/secrets/redirect-proxy/admin-password
ports:
- name: http
containerPort: 8080
- name: admin
containerPort: 8081
livenessProbe:
httpGet:
path: /healthz
port: admin
readinessProbe:
httpGet:
path: /readyz
port: admin
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: data
mountPath: /var/lib/redirect-proxy
- name: secrets
mountPath: /run/secrets/redirect-proxy
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: redirect-proxy-data
- name: secrets
secret:
secretName: redirect-proxy
---
# The admin and the API: inside the cluster only (or behind an
# authenticating ingress)
apiVersion: v1
kind: Service
metadata:
name: redirect-proxy-admin
spec:
type: ClusterIP
selector:
app: redirect-proxy
role: writer
ports:
- name: admin
port: 8081
targetPort: admin- One replica and the
Recreatestrategy: two writers must never share the data directory, not even during a rollout. - The image's environment sets the data directory (
RP_STORAGE_DATA_DIR=/var/lib/redirect-proxy) and binds the admin to0.0.0.0:8081(RP_ADMIN_LISTEN,RP_ADMIN_PORT), so that the kubelet can probe/healthz(liveness) and/readyz(readiness, 503 while draining). Expose port 8081 only inside the cluster, or through an authenticating ingress.
The replicas take the traffic. They read redirects.json from the redirect-proxy-rules ConfigMap, mounted as their data directory:
apiVersion: apps/v1
kind: Deployment
metadata:
name: redirect-proxy
spec:
replicas: 3
selector:
matchLabels:
app: redirect-proxy
role: replica
template:
metadata:
labels:
app: redirect-proxy
role: replica
spec:
# above server.shutdown_grace, with room for the VM to stop
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
containers:
- name: redirect-proxy
image: ghcr.io/janalis/redirect-proxy:latest
env:
- name: RP_ADMIN_ENABLED
value: "false"
- name: RP_API_ENABLED
value: "false"
- name: RP_STORAGE_DATA_DIR
value: /var/lib/redirect-proxy
- name: RP_STORAGE_WATCH_INTERVAL
value: "10"
- name: RP_SERVER_HEALTH_PATH
value: /_health
# longer than readiness periodSeconds x failureThreshold (5 x 2)
- name: RP_SERVER_SHUTDOWN_GRACE
value: "15"
# the ingress controller's pods: their X-Forwarded-* count
- name: RP_ACCESS_LOG_TRUSTED_PROXIES
value: 10.0.0.0/8
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /_health
port: http
periodSeconds: 5
failureThreshold: 2
livenessProbe:
tcpSocket:
port: http
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
# the whole ConfigMap as the data directory: no subPath, which
# would never be updated
- name: rules
mountPath: /var/lib/redirect-proxy
readOnly: true
volumes:
- name: rules
configMap:
name: redirect-proxy-rules
items:
- key: redirects.json
path: redirects.json
---
apiVersion: v1
kind: Service
metadata:
name: redirect-proxy
spec:
selector:
app: redirect-proxy
role: replica
ports:
- name: http
port: 80
targetPort: httpRP_ADMIN_ENABLED=falseandRP_API_ENABLED=false: replicas only redirect and write nothing to their data directory, so the ConfigMap and the root file system can be read-only.- Mount the ConfigMap as a directory, never with
subPath: Kubernetes updates a mounted ConfigMap by swapping the whole directory, whichstorage.watch_interval(RP_STORAGE_WATCH_INTERVAL, seconds) picks up, while asubPathmount keeps the first version forever. The kubelet itself takes up to about a minute to update the files. A ConfigMap holds at most 1 MiB: for more rules, use a volume synced from git or the writer's volume mounted read-only. server.health_path(RP_SERVER_HEALTH_PATH) gives the readiness probe a path on the traffic port, which answers 503 while draining. The liveness probe only checks that the port accepts connections.RP_ACCESS_LOG_TRUSTED_PROXIESlists the addresses the ingress controller connects from (here a placeholder for the pod CIDR): see HTTP to HTTPS.
Publish the writer's rules to the replicas from its API (a client with redirects:read), or from a file kept in git:
curl -s -H "Authorization: Bearer $TOKEN" \
http://redirect-proxy-admin:8081/api/redirects/export > redirects.json
kubectl create configmap redirect-proxy-rules --from-file=redirects.json \
--dry-run=client -o yaml | kubectl apply -f -Shutdown and probes
On SIGTERM a pod drains for server.shutdown_grace seconds (RP_SERVER_SHUTDOWN_GRACE, default 5): its readiness answers 503 and it closes connections after each response, then stops (see Shutdown). Kubernetes removes a terminating pod from its Services on its own, but that takes a moment to reach every node and ingress controller, and load balancers that probe the pods themselves only move away once readiness has failed failureThreshold times, periodSeconds apart. Size the grace above periodSeconds × failureThreshold (5 × 2 = 10 seconds above, hence 15), and terminationGracePeriodSeconds (default 30) above the grace, or the pod is killed while it drains.
Docker Compose
nginx terminates TLS in front of redirect-proxy, which forwards what no rule answers to the website. docker-compose.yml:
services:
nginx:
image: nginx:1.30-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/ssl/site:ro
depends_on:
- redirect-proxy
networks: [web]
redirect-proxy:
image: ghcr.io/janalis/redirect-proxy:latest
read_only: true
environment:
RP_SERVER_UPSTREAM: http://website:80
RP_SERVER_HTTPS_REDIRECT: "true"
# nginx connects from the web network, not from 127.0.0.1
RP_ACCESS_LOG_TRUSTED_PROXIES: 172.30.0.0/24
RP_ADMIN_PUBLIC_URL: http://localhost:8081
RP_BOOTSTRAP_ADMIN_USERNAME: admin
RP_BOOTSTRAP_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
secrets: [admin_password]
ports:
# the admin and the API, on this host only
- "127.0.0.1:8081:8081"
volumes:
- data:/var/lib/redirect-proxy
stop_grace_period: 15s
networks: [web]
website:
image: traefik/whoami # your website
networks: [web]
networks:
web:
ipam:
config:
- subnet: 172.30.0.0/24
volumes:
data:
secrets:
admin_password:
file: ./admin_password.txtnginx.conf, as in Web servers, with the service name as the upstream server:
upstream redirect_proxy {
server redirect-proxy:8080;
keepalive 32;
}
map $http_upgrade $websocket_upgrade {
~*^websocket$ $http_upgrade;
default "";
}
map $http_upgrade $connection_upgrade {
~*^websocket$ upgrade;
default "";
}
server {
listen 80;
listen 443 ssl;
server_name _;
ssl_certificate /etc/ssl/site/site.crt;
ssl_certificate_key /etc/ssl/site/site.key;
location / {
proxy_pass http://redirect_proxy;
proxy_http_version 1.1;
proxy_set_header Upgrade $websocket_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}- The
webnetwork has a fixed subnet so thatRP_ACCESS_LOG_TRUSTED_PROXIEScan list it: nginx does not connect from127.0.0.1(see HTTP to HTTPS). admin_password.txtholds the first administrator's password, readable by the container's user (uid 10001). The admin is published on the host's loopback only.- The image's health check probes the admin's
/healthz. The container's root file system is read-only; the data directory is thedatavolume. stop_grace_period(Docker's default is 10 seconds) stays aboveserver.shutdown_grace(5).
