Run doorman in front of a whole estate: wildcard host, certificate, the shared network, and adding a customer with no infrastructure work.
The shape is one doorman per host, owning a wildcard hostname, with every
customer project on a shared docker network and no public hostname of its own.
The worked example below is *.arvo.works on a box that also runs Coolify and
Traefik; nothing about doorman requires either.
Doorman is the only container attached to both:
docker network create arvo-works # where the customer containers live
# 'coolify' (or whatever network your reverse proxy is on) already exists
Customer containers therefore never touch the proxy network and cannot see the
rest of the estate. Set DOORMAN_UPSTREAM_NETWORK in .env to whatever you
called it.
If you run Coolify, skip the create and add it as a destination instead
(Servers → the server → Destinations → Add). Coolify creates the network itself,
and doing it by hand first makes that step fail with network already exists.
The same error on a retry usually means the destination was already saved —
check with
docker exec coolify-db psql -U coolify -c 'select name, network from standalone_dockers;'
before creating anything.
Doorman is one container with hand-written labels, so compose earns nothing.
deploy/run.sh in the repo is the script that runs in production: a plain
docker run on the proxy network, then docker network connect for the
customer network — docker run accepts only one --network.
mkdir -p /opt/doorman && cd /opt/doorman
install -m 0755 <repo>/deploy/run.sh .
cat > doorman.env <<EOF
DOORMAN_VERSION=0.1.1
DOORMAN_ADMIN_TOKEN=$(openssl rand -hex 32)
DOORMAN_UPSTREAM_NETWORK=arvo-works
EOF
chmod 600 doorman.env
docker login registry.agentics.dk # the image is not public
./run.sh
Give the host a pull-only credential scoped to this one image, not a push identity — a
preview gateway needs to read one repository and nothing else. run.sh pulls before it
recreates the container and treats a failed pull as fatal, because docker run pulls a tag it
has never seen but never refreshes one it already has: a host that was once handed an image by
hand would otherwise keep serving it long after the tag moved. DOORMAN_NO_PULL=1 opts out for
a local build and says so on stdout.
deploy/docker-compose.yml + deploy/deploy.sh are the same thing for estates
that do use compose. Pick one, not both. Either way doorman-data:/data is
mounted, both networks are joined, and the router labels below are set.
| Variable | Meaning |
|---|---|
USER_DATA_DIR | File store root. Default /data; must be a volume. |
DOORMAN_ADMIN_TOKEN | Bootstrap bearer token for the admin API. Required. |
DOORMAN_RESERVED_PREFIX | Path prefix doorman keeps for itself. Default /_doorman. |
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET | Keycloak, for staff SSO and token-authenticated admin calls. Optional. |
DOORMAN_STAFF_ROLE | Realm role required for staff SSO. Empty = any authenticated user. |
DOORMAN_ADMIN_AUDIENCE | Audience enforced on admin bearer tokens. Optional. |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_FROM_NAME, SMTP_STARTTLS | Magic-link delivery. Without SMTP_HOST magic links are simply off. |
DOORMAN_MAX_PIN_ATTEMPTS, DOORMAN_LOCKOUT_MINUTES | PIN lockout per (site, address). Defaults 8 and 15. |
DOORMAN_MAGIC_LINK_MINUTES | Magic-link lifetime. Default 30. |
DOORMAN_SEED_FILE | A JSON file holding one site object, or an array of them, applied at startup. Optional. |
DOORMAN_SEED_MODE | ensure (default) writes only sites the store does not have; replace lets the file win on every boot. |
The estate above creates its sites from the portal, so the store is never empty for long. The
other deployment — doorman inside a product's own image, on the public port, proxying to that
one application on loopback — has nobody to call an admin API before the first request arrives,
and a cold /data volume then means an instance with no sites answering 503 for a hostname that
already has DNS and a certificate pointing at it.
Point DOORMAN_SEED_FILE at a file baked into the image:
{
"slug": "commuteconnects",
"displayName": "Commute Connects",
"hosts": ["commuteconnects.com", "www.commuteconnects.com"],
"upstream": { "host": "127.0.0.1", "port": 8080 },
"access": { "public": true },
"injection": { "noIndex": false, "blockCrawlerUserAgents": false },
"analytics": { "enabled": true }
}
That is the shape of site.json and of the admin API's PUT body, and the seed goes through the
same save path — the same clamps, the same host normalisation, the same consent-cookie default.
Note the three flags public mode actually needs: access.public alone still serves noindex and
still 403s crawler user agents, because the crawler policy runs before the gate.
Two things follow from the mode:
ensure is the default and the one to deploy with. A redeploy never clobbers a PIN handed
to a customer, an appearance change made in the portal, or an operator's edit. When the file
and the stored record differ, the boot logs a warning naming the fields and keeps the record.replace makes the file authoritative on every boot. The stored createdAt is carried
over, because it is the same site.Everything that can be wrong with the file — it is missing, it does not parse, a site has no host or no upstream, two sites claim one hostname — stops the container at startup. A crash-loop is visible in the deployment log; a healthy container serving 503 to every visitor is not.
No secrets in a seed file. sso.clientSecret is write-only and never lands in site.json,
but a file baked into an image is readable by anyone who can pull the image. Set the secret once
through the admin API after the first boot.
Doorman answers for every subdomain, so the router is a regexp — which is exactly what most control panels cannot express. Write the labels by hand:
traefik.enable: "true"
traefik.docker.network: coolify # required: the container is multi-homed
traefik.http.routers.doorman.entrypoints: https
traefik.http.routers.doorman.rule: HostRegexp(`^.+\.arvo\.works$`)
traefik.http.routers.doorman.priority: "1"
traefik.http.routers.doorman.tls: "true"
traefik.http.services.doorman.loadbalancer.server.port: "8080"
Two traps live in those six lines:
Host(arvo.works) router of your marketing site, so without an
explicit low priority the gateway swallows the public site.traefik.docker.network. Doorman sits on two networks; without this
Traefik may pick the customer-side address and never reach it.HTTP-01 cannot issue a wildcard and a regexp router gives ACME nothing to enumerate, so the domain must be named explicitly and answered over DNS-01. Check the proxy before installing anything — a Traefik that already has a DNS challenge provider can do it from doorman's own labels:
docker inspect coolify-proxy --format '{{json .Args}}' | tr ',' '\n' | grep -i acme
If you see dnschallenge.provider=… with credentials on the container, add two
labels and you are done:
traefik.http.routers.doorman.tls.certresolver: letsencrypt
traefik.http.routers.doorman.tls.domains[0].main: "*.arvo.works"
That is the arvo.works case: Coolify's proxy already carried Cloudflare credentials and had issued wildcards for other zones.
Only if the resolver is HTTP-01 only, issue out of band and hand the files to the file provider — certificates cannot be attached by container label:
lego --dns cloudflare --domains '*.arvo.works' --domains arvo.works \
--email you@example.com --accept-tos run
# then declare it:
# /data/coolify/proxy/dynamic/arvo-wildcard.yaml → tls.certificates: [...]
Do not set a defaultCertificate there — that changes the fallback certificate
for every router on the box. deploy/renew-cert.sh and
deploy/arvo-wildcard.yaml in the repo do both steps and document the weekly
cron.
Either way, check what is actually served:
echo | openssl s_client -connect <box-ip>:443 -servername kunde.arvo.works \
2>/dev/null | openssl x509 -noout -subject # subject=CN = *.arvo.works
No infrastructure work. Deploy the project as usual with two unusual settings:
| Field | Value |
|---|---|
| Domains / FQDN | empty — the project must have no public hostname |
| Destination / network | the customer network, e.g. arvo-works |
| Ports exposed | the app's port, e.g. 3000 |
| Custom docker network aliases | the site slug, e.g. griller |
The alias is what makes this work. Coolify names containers
{app-uuid}-{timestamp} and the timestamp changes on every deploy, so the
generated name is useless as an upstream; custom_network_aliases is merged into
the network's aliases next to it and stays stable.
Then one call (admin API):
curl -X PUT https://kunde.arvo.works/_doorman/api/v1/sites/griller \
-H "Authorization: Bearer $DOORMAN_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"displayName":"Griller.dk","hosts":["griller.arvo.works"],
"upstream":{"host":"griller","port":3000},
"access":{"pinEnabled":true},"injection":{"banner":true}}'
A visitor who is through the gate but whose site is not running gets doorman's own page — same card as the gate, in the site's language and accent — rather than the browser's "this page isn't working". The status code says which of the three things went wrong, and the page's small print repeats it as a code the visitor can pass on:
| Status | Code | What happened |
|---|---|---|
503 + Retry-After: 30 | upstream-not-running | Nothing is listening on that alias: not deployed, or mid-redeploy. |
504 | upstream-timeout | It accepted the connection and never answered. |
502 | upstream-bad-response | It answered, and the answer was unusable. |
The 503 is a deliberate departure from nginx, which answers 502 for a refused
connection. A connection that was never established is not "an invalid response
from an inbound server" (RFC 9110 §15.6.3) — it is "currently unable to handle
the request … likely to be alleviated after some delay" (§15.6.4), and it is the
only one of the three that can honestly carry a Retry-After. Envoy and HAProxy
answer 503 for the same condition. The practical payoff: an undeployed preview
and a crashing app stop looking identical in an uptime monitor.
XHR and asset requests get the status and the code as plain text, never the page. The page names no hostname, port or exception — a visitor cannot act on those, and on a public site anyone would see them. The full detail is in doorman's log, one warning per failure with the same code.
# 1. the alias resolves from inside doorman — the first thing to check on a 503
docker exec doorman curl -sI http://griller:3000 | head -1
# 2. the site gates
curl -sI https://griller.arvo.works | head -1 # 302 → /_doorman/gate
# 3. the marketing site is untouched (the priority trap)
curl -sI https://arvo.works | head -1
# 4. the box runs the build you think it does
docker exec doorman curl -fsS http://127.0.0.1:8080/healthz
# {"status":"ok","version":"0.1.1"}
cd /opt/doorman && DOORMAN_VERSION=<version> ./run.sh
Keep the doorman-data volume across upgrades. It holds the Data Protection key
ring, and a fresh key ring invalidates every access cookie and every magic or
share link already in a customer's inbox.
/_doorman/* or /robots.txt
loses them to doorman. Move the prefix with DOORMAN_RESERVED_PREFIX if that
ever collides./data. It is plain files — tar czf is a complete backup,
including the audit trail you may be asked to produce later.