Skip to main content

Web Router (nginx reverse proxy)

The host-wide nginx reverse proxy that puts domains in front of Odoo instances and other services — formerly set up by the ansible-web_router role.

It runs as its own docker-compose stack, separate from the per-project odoo stack, and is deliberately not touched by odoo up / odoo down / odoo restart. Steer it explicitly with odoo router ….

Two install modes:

ModeInstall dirUse
--global/opt/proxythe host-wide router
project (default)<WORKING_DIR>/.odoo/router/one router for this project

Commands​

odoo router setup --global # install or update the stack
odoo router setup --global --vhosts-file f.yml # replace vhosts.yml wholesale
odoo router setup --global --host-network # router in the host network (see below)

odoo router vhost list --global # what is configured
odoo router vhost show --global <domain> # one vhost as YAML
odoo router vhost new --global # wizard (see below)
odoo router vhost add --global <file>.yml # one vhost from a file
odoo router vhost remove --global <domain>

odoo router apply-vhosts --global # re-render + reload nginx
odoo router ssl --global # issue certs (certbot + self-signed)
odoo router restart|reload|down|docker-status --global

Router in the host network​

--host-network runs the router with network_mode: host instead of joining docker networks (--network; the two exclude each other). Meant for machines where the router and the project proxies run under different docker daemons, e.g. one unix user per instance with rootless docker: there is no network to share, so each proxy publishes on loopback only (PROXY_IP=127.0.0.1, PROXY_PORT=<port>) and the vhost points at upstream_server: 127.0.0.1, upstream_port: <port>. From a bridge network the router would need that port open towards docker0, which a firewall allowing only 22/80/443 blocks.

The switch is applied at every setup; a later setup without it puts the router back into its bridge network.

The config menu​

odoo router config --global

A guided menu, navigated with the arrow keys: create, edit, delete and show vhosts, render + reload, issue certificates. It stays open until you quit, and if you leave with unsaved-but-unapplied changes it offers to apply them.

The list shows where each vhost points and marks incomplete ones:

kunde.zebroo.de [upstream_direct_odoo] -> 192.168.77.130:6000
grafana.zebroo.de [upstream] -> 192.168.77.6:8080
alt.zebroo.de [redirect]
broken.zebroo.de [upstream] (incomplete: upstream_server, timeout)

Editing walks the fields of the chosen vhost, with the current value as the default. Optional fields that are not set yet are offered with a + and a short explanation; clearing an optional field removes it rather than storing an empty value.

When creating, values are suggested where that is possible: the backend address defaults to whatever the other vhosts point at, the port to one above the highest already in use, and the upstream name is derived from the domain.

The single-shot wizard​

odoo router vhost new asks for everything a vhost needs, validates it, shows the result and offers to write and apply it.

odoo router vhost new --global
odoo router vhost new --dry-run # only print it, touch nothing, no router needed

--dry-run is the quickest way to get a correct vhost snippet to paste somewhere else — it does not need an installed router.

Rarely used fields (locations, headers, before_server_conf, client_max_body_size, rate_limit, custom certificates) are not asked for; add those by hand afterwards.

vhosts.yml​

Configuration lives in <install_dir>/vhosts.yml and is a list of vhosts — the same schema as the ansible web_router.virtual_hosts list. JSON works just as well: the file is read with yaml.safe_load, and YAML is a superset of JSON.

A complete, commented example is in router_global/vhosts.example.yml.

- template: upstream_direct_odoo
server_name: kunde.zebroo.de
upstream_name: kunde_odoo
upstream_server: 192.168.77.130
upstream_port: 6000
timeout: 600
use_certbot: true

Templates and their required fields​

TemplateRequiredWhat it is for
upstream_direct_odooserver_name, upstream_name, upstream_server, upstream_port, timeoutAn Odoo instance. Also creates the chat/longpolling upstream on port 8072.
upstreamsame as aboveAny other service behind the proxy.
redirectserver_name, redirect_toRedirect to another address.
static_filesserver_name, folderServe a directory.
existing_ssl_certificatesserver_nameCertificate block only, no backend.
default_servernothing — the router adds it itselfThe catch-all for unknown names (see below).

With a custom certificate (ssl_key_is_on_destination or ssl_server_cert) certificate_name becomes required as well — it is the directory name under /etc/ssl/custom_ssl/.

The catch-all vhost​

Every router renders one extra vhost that is not in vhosts.yml: sites-enabled/00_default. It carries default_server on both ports and answers 404 for every name the router does not serve, including a call to the bare IP.

It exists because nginx otherwise promotes the first server block it parses to the default — and the blocks are pulled in by include sites-enabled/*, that is, by filename. Deploying a vhost whose name happens to sort first then silently hands it all stray traffic. On hy-router a newly added 3dm-monitoring.zebroo.de did exactly that: from that moment the bare router IP, and every unknown domain pointing at it, was redirected into an internal monitoring site.

Its certificate is self-signed (custom_ssl/_default/) — whoever lands here asked for a name this router does not serve, so no certificate could match anyway, and nginx needs one to answer on 443 at all. ACME challenges stay reachable through it, because a certificate is validated before the vhost it belongs to exists.

To use a different catch-all, put your own entry with template: default_server in vhosts.yml — a hand-written one wins and nothing is appended.

Plain HTTP on a TLS vhost​

A vhost that terminates TLS gets a second, small server block on port 80 that answers 301 to the HTTPS address. It is rendered for every vhost with TLS, use_certbot included.

That last part used to be certbot's job, and it did not hold. certbot writes its redirect block into the delivered file; the next render overwrites that file, and _reinstall_certbot_tls carries back the TLS directives alone — a whole server block must not end up nested inside another one. So every template change quietly took port 80 away from a few more vhosts. On hy-router it had reached 17 of 163, hosting.zebroo.de and ntfy.zebroo.de among them; with the catch-all vhost in place they answered 404 on plain HTTP.

For use_certbot vhosts the block keeps /.well-known/acme-challenge/ open instead of redirecting it: HTTP-01 is validated over plain HTTP, and the first certificate is issued when there is nothing on 443 to redirect to yet.

A vhost without TLS gets no such block — its own server block is the one listening on port 80.

Optional fields​

FieldTypeMeaning
use_certbotboolodoo router ssl issues a Let's Encrypt certificate for it.
ssl_self_signedboolTerminate TLS with a self-signed certificate (no Let's Encrypt / ACME). See Offline / self-signed.
filenamestringFile name under sites-enabled (default: server_name).
allowed_ipsstringOnly these networks reach the vhost, everything else gets 403. Comma or semicolon separated — not a list. The ACME path stays open.
allowlist_public_pathsstringPaths that stay reachable despite the allowlist, e.g. an API that authenticates itself. Same separator rules.
basic_authdictuser: password in clear text; hashed into htpasswd/ with openssl passwd -apr1.
rate_limitstringPer-IP rate limit in nginx syntax, e.g. 30r/m.
rate_limit_burstintBurst allowance for the rate limit (default 20).
client_max_body_sizestringUpload limit, default 1024M. 0 means no limit.
proxy_request_bufferingboolfalse streams the body upstream instead of spooling it to disk first — for multi-gigabyte uploads.
upstream_schemestringhttps if the backend speaks TLS itself.
locations, headers, before_server_confstringRaw nginx snippets pasted into the config.

redirect_to and paths​

If redirect_to contains a /, everything lands exactly there. Without a path the requested path is appended ($request_uri):

redirect_to: zebroo.de # /foo -> zebroo.de/foo
redirect_to: zebroo.de/zebroo-experience # /foo -> zebroo.de/zebroo-experience

Offline / self-signed TLS​

On a protected LAN the router cannot reach Let's Encrypt, so use_certbot will never verify and odoo router ssl fails, leaving the vhost listening on port 80 only. For that case use ssl_self_signed:

- template: upstream
server_name: lan.internal.zebroo.de
upstream_name: lan_internal
upstream_server: 192.168.77.20
upstream_port: 8069
timeout: 600
ssl_self_signed: true

What it does:

  • The template emits listen 443 ssl for the vhost and a listen 80 block that 301-redirects to https://$host$request_uri, so plain HTTP bounces to TLS instead of hitting the 444 catch-all. This now applies to every TLS vhost (use_certbot, ssl_self_signed, and the custom-cert ssl_key_is_on_destination / ssl_server_cert modes alike), not just self-signed — a pre-existing custom-cert vhost that used to get a 444 on port 80 now gets the redirect.
  • The cert + key are generated on the host with openssl when the vhost is applied (apply-vhosts / setup / vhost add) and stored in custom_ssl/<name>/server.{crt,key} — with subject CN and a subjectAltName of server_name, ~10 year validity. A browser will still warn (it is self-signed) but the TLS connection works.
  • certificate_name is optional; when omitted it defaults to server_name.
  • It is idempotent: if server.crt and server.key already exist in that directory (e.g. you put a certificate there by hand) they are kept and never overwritten — provided always wins.
  • No ACME / internet / odoo router ssl involved; odoo router ssl is a no-op for such a vhost (it still reloads nginx).

odoo router ssl remains the command for use_certbot vhosts; it now also ensures any ssl_self_signed certs are in place and reloads, so one command can cover a mixed set.

Things that cost time​

upstream_name may only contain letters, digits and underscores. It ends up in an nginx variable (set $var_<name>), so a dot or a dash produces a config nginx refuses to load — and that only surfaces at reload time, not while rendering. The wizard rejects such names.

upstream_server is the LAN address of the machine, not its VPN address: the router reaches the instances over the LAN.

A missing field used to render as an empty string. render_configs.py now renders with StrictUndefined, so a missing field aborts with the vhost name and the field name. Before that, a vhost without upstream_server silently produced a broken config:

upstream {
server :;
}
proxy_pass http://$var_;
proxy_connect_timeout ;

If you are chasing an older router that behaves oddly, nginx -t inside the container shows this; rendering did not.