Reverse proxy
Publish AnsibleForms behind Nginx, Traefik or Apache, at the root or under a subpath
- When to use one
- The settings that matter
- Serving under a subpath
- Timeouts, uploads and live output
- Runner WebSockets
- Request header size
- Client addresses
- Examples
When to use one
AnsibleForms serves the interface, REST API and Swagger on one port; a reverse proxy in front of it lets you:
- serve it on port 443 with a certificate managed by your existing tooling (ACME, a corporate CA)
- share one host name with other applications, by publishing AnsibleForms under a subpath
- keep the application port and the database off the public network
A proxy is optional: the application can serve HTTPS itself, see HTTPS.
The settings that matter
Only a few settings affect how AnsibleForms behaves behind a proxy:
| Setting | Behind a proxy |
|---|---|
PORT | The port the proxy forwards to, 8000 by default. The docker-compose project sets it to 443. |
HTTPS | 0 when the proxy terminates TLS and reaches the application over a private network, 1 to encrypt that hop as well. |
BASE_URL | The subpath, for example /ansibleforms. Leave it at / when the application has a host name of its own. |
TRUST_PROXY | Which proxies may report the client address in X-Forwarded-For, usually 1. See Client addresses. |
API_BODY_LIMIT_MB | Largest JSON request the application accepts, 50 MB by default. The proxy must allow at least this. |
UPLOAD_MAX_GB | Largest file a form upload field accepts, 10 GB by default. The proxy must allow it too, or uploads fail there. |
MAX_HTTP_HEADER_SIZE | Largest set of request headers the application accepts, 16 KB by default, from 7.1.0. See Request header size. |
| Public root URL | Under System > General, on the Server tab: the address users reach, subpath included. It builds the links in notification mails, channel messages and approval requests. |
For Entra ID or OIDC, register the public address as redirect URI, with the BASE_URL subpath if set.
Serving under a subpath
BASE_URL mounts the whole application under the subpath; the proxy must forward the path unchanged. With BASE_URL=/ansibleforms:
- the interface,
/api/v2/...and the Swagger interface are all served under/ansibleforms/ - the
<base href>of the interface is rewritten at startup, so assets and API calls resolve under the subpath - a request for
/or for/ansibleforms(no trailing slash) is redirected to/ansibleforms/
Keep the prefix: no StripPrefix in Traefik, no URI on proxy_pass in Nginx, as the application expects it.
The Status page shows the base URL in force.
Timeouts, uploads and live output
The jobs page and the job counters hear of changes through one long-lived stream of server-sent events, /api/v2/events, held open per browser. The stream sends headers that turn proxy buffering off (X-Accel-Buffering: no) and a keep-alive line every 15 seconds, so the usual 60 second read timeout keeps it open; when a proxy closes it anyway, the browser reconnects on its own. The output under a form, right after a launch, is read every two seconds instead. A job launch returns as soon as the job is created, so a long playbook does not hold a request open.
The requests that can take long are:
- backup and restore from the Backups page, which wait for the dump or restore command to finish, up to
BACKUP_COMMAND_TIMEOUT_SECONDS(3600 by default) - large uploads through a file field, which stream the whole file in one request
- chat messages, when the chat assistant is enabled, which wait for the model provider to answer
Proxies often time out after 60 seconds and cap request bodies (Nginx accepts 1 MB by default). Raise both for the AnsibleForms routes, as in the examples below.
Runner WebSockets
From 7.3.0 an outbound Runner dials the application and keeps one WebSocket open on <BASE_URL>/api/v2/rte/socket. The proxy must pass the upgrade on, or the Runner never connects and keeps dialing:
| Proxy | What the WebSocket needs |
|---|---|
| Nginx | proxy_http_version 1.1 and the Upgrade and Connection headers, which Nginx does not forward by default |
| Traefik | Nothing: WebSockets pass through as they are |
| Apache | upgrade=websocket on the ProxyPass line (2.4.47 and later), or mod_proxy_wstunnel before that |
Both ends send a ping every 20 seconds, so the usual 60 second read timeout keeps an idle socket open.
Request header size
Every request carries the sign-in token in its Authorization header, next to cookies and the headers the proxy adds. Both the proxy and the application cap the size of the request headers, and refuse a request above the cap before AnsibleForms sees it, so nothing reaches the server log. The defaults:
| Component | Limit | Default | Refused with |
|---|---|---|---|
| AnsibleForms (Node.js) | MAX_HTTP_HEADER_SIZE, all headers together | 16384 bytes | 431 |
| Nginx | large_client_header_buffers, per header field | 4 8k: 8 KB per field | 400 |
| Traefik | http.maxHeaderBytes of the entry point, all headers together | 1 MB | 431 |
| HAProxy | tune.bufsize in the global section, the whole header block, less the tune.maxrewrite reserve (1024 bytes) | 16384 bytes: 15360 bytes of headers | 400 |
| Apache | LimitRequestFieldSize, per header field | 8190 bytes | 400 |
Up to 7.0.3 the sign-in token carried every group of a single sign-on user, and could outgrow these limits, see Empty forms and jobs after a single sign-on. From 7.1.0 the token stays small, and the defaults are enough. When something else makes the headers large, such as cookies of other applications on the same domain or a single sign-on gateway in front, raise the limits together:
- AnsibleForms:
MAX_HTTP_HEADER_SIZE=65536, or before 7.1.0NODE_OPTIONS=--max-http-header-size=65536; both are read at start, so restart the application after a change - Nginx:
large_client_header_buffers 4 32k;in thehttporserverblock - HAProxy:
tune.bufsize 65536in theglobalsection; every stream gets a buffer of that size, so HAProxy advises lowering the globalmaxconnby the same factor, and HTTP/2 needs at least the default 16384 - Apache:
LimitRequestFieldSize 32768in the server config or the virtual host
Client addresses
Behind a proxy every connection comes from the proxy. Without TRUST_PROXY, the audit trail records the proxy’s address for every action, and the per-address sign-in limits count all clients as one address. TRUST_PROXY says which proxies the application may believe when they report the client in X-Forwarded-For:
| Value | Client address |
|---|---|
0 (default) | The address of the connection. X-Forwarded-For is ignored; empty, false, off and no mean the same. |
A number, e.g. 1 | Trusts that many proxies in front of the application: 1 for a single reverse proxy, 2 for a load balancer in front of an ingress. |
A list, e.g. 10.0.0.5,172.16.0.0/12 | Trusts the proxies at these addresses and CIDR ranges. The presets loopback, linklocal and uniquelocal are accepted too. |
true | Trusts every hop: the left-most address of X-Forwarded-For is used. |
The application walks X-Forwarded-For from the right, skips the trusted proxies, and takes the first address that is not one of them. Use the exact number of proxies, or their addresses:
X-Forwarded-Foris a plain request header that any client can send. A number that is too high, ortrue, lets a client choose the address the audit trail records.trueis only safe when the application can be reached through the proxy alone, and the server logs a[SECURITY]warning at start when it is set.- The proxy must add the address it received the connection from, as the examples below do (
$proxy_add_x_forwarded_forin Nginx; Traefik and Apachemod_proxyadd it by default). - An invalid value is refused on the settings page; set in the environment, it is logged as an error at start and no proxy is trusted. A change saved on the settings page applies without a restart.
Examples
Each example publishes AnsibleForms at https://forms.example.com/ansibleforms/, with TLS terminated by the proxy and the application running with HTTPS=0, PORT=8000, BASE_URL=/ansibleforms and TRUST_PROXY=1:
proxy_pass names no URI, so Nginx forwards the path as it came in, prefix included. The map block goes in the http context (a file under conf.d/ is included there), and passes the WebSocket upgrade of the Runners on:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name forms.example.com;
ssl_certificate /etc/nginx/certs/forms.example.com.crt;
ssl_certificate_key /etc/nginx/certs/forms.example.com.key;
location /ansibleforms/ {
proxy_pass http://10.0.0.20:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1; # the Runners' WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
client_max_body_size 10g; # UPLOAD_MAX_GB
proxy_request_buffering off; # stream uploads instead of spooling them first
proxy_read_timeout 3600s; # BACKUP_COMMAND_TIMEOUT_SECONDS
proxy_send_timeout 3600s;
}
}
Without a subpath, use location / and leave BASE_URL unset.
Labels on the app service of the docker-compose project, with Traefik on the same Docker network. The service publishes no port of its own, Traefik reaches it on the network:
app:
image: ghcr.io/ansibleforms/ansibleforms:7
environment:
- HTTPS=0
- PORT=8000
- BASE_URL=/ansibleforms
- TRUST_PROXY=1 # Traefik is the one proxy in front
labels:
- traefik.enable=true
- traefik.http.routers.ansibleforms.rule=Host(`forms.example.com`) && PathPrefix(`/ansibleforms`)
- traefik.http.routers.ansibleforms.entrypoints=websecure
- traefik.http.routers.ansibleforms.tls=true
- traefik.http.routers.ansibleforms.tls.certresolver=letsencrypt
- traefik.http.services.ansibleforms.loadbalancer.server.port=8000
There is no StripPrefix middleware on purpose. Traefik sets the X-Forwarded-* headers itself and does not cap the body size, but check the respondingTimeouts of the entry point when backups or uploads take longer than its read timeout. Without a subpath, keep only the Host(...) part of the rule.
Needs mod_proxy, mod_proxy_http, mod_headers, mod_ssl, and Apache 2.4.47 or later for the Runners’ upgrade=websocket. Both sides of ProxyPass carry the prefix:
<VirtualHost *:443>
ServerName forms.example.com
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/forms.example.com.crt
SSLCertificateKeyFile /etc/pki/tls/private/forms.example.com.key
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass /ansibleforms/ http://10.0.0.20:8000/ansibleforms/ timeout=3600 upgrade=websocket
ProxyPassReverse /ansibleforms/ http://10.0.0.20:8000/ansibleforms/
# no body cap here : UPLOAD_MAX_GB and API_BODY_LIMIT_MB apply in the application
LimitRequestBody 0
</VirtualHost>
Without a subpath, proxy / to http://10.0.0.20:8000/ and leave BASE_URL unset.
To keep the hop between proxy and application encrypted, run the application with HTTPS=1, point the proxy at https://, and give the application a certificate the proxy trusts, see HTTPS.