Self-hosting

beta

Run doorman in front of a whole estate: wildcard host, certificate, the shared network, and adding a customer with no infrastructure work.

Self-hosting

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.

The two networks

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.

Run it

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.

Configuration

VariableMeaning
USER_DATA_DIRFile store root. Default /data; must be a volume.
DOORMAN_ADMIN_TOKENBootstrap bearer token for the admin API. Required.
DOORMAN_RESERVED_PREFIXPath prefix doorman keeps for itself. Default /_doorman.
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRETKeycloak, for staff SSO and token-authenticated admin calls. Optional.
DOORMAN_STAFF_ROLERealm role required for staff SSO. Empty = any authenticated user.
DOORMAN_ADMIN_AUDIENCEAudience enforced on admin bearer tokens. Optional.
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_FROM_NAME, SMTP_STARTTLSMagic-link delivery. Without SMTP_HOST magic links are simply off.
DOORMAN_MAX_PIN_ATTEMPTS, DOORMAN_LOCKOUT_MINUTESPIN lockout per (site, address). Defaults 8 and 15.
DOORMAN_MAGIC_LINK_MINUTESMagic-link lifetime. Default 30.
DOORMAN_SEED_FILEA JSON file holding one site object, or an array of them, applied at startup. Optional.
DOORMAN_SEED_MODEensure (default) writes only sites the store does not have; replace lets the file win on every boot.

Seeding a site with the image

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.

The wildcard router

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:

  • Priority. Traefik v3 ranks routers by rule length. The regexp is longer than the 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.

The certificate

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

Adding a customer

No infrastructure work. Deploy the project as usual with two unusual settings:

FieldValue
Domains / FQDNempty — the project must have no public hostname
Destination / networkthe customer network, e.g. arvo-works
Ports exposedthe app's port, e.g. 3000
Custom docker network aliasesthe 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}}'

When the upstream is not there

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:

StatusCodeWhat happened
503 + Retry-After: 30upstream-not-runningNothing is listening on that alias: not deployed, or mid-redeploy.
504upstream-timeoutIt accepted the connection and never answered.
502upstream-bad-responseIt 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.

Verifying

# 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"}

Updating

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.

Operational notes

  • One instance. PIN and share-link use counters are guarded by an in-process lock over the file store; two replicas on one volume would make "single-use" links merely "rarely reused".
  • Reserved paths. A customer app that serves /_doorman/* or /robots.txt loses them to doorman. Move the prefix with DOORMAN_RESERVED_PREFIX if that ever collides.
  • Back up /data. It is plain files — tar czf is a complete backup, including the audit trail you may be asked to produce later.