drobek / docs

Self-hosting drobek

docs/SELF-HOSTING.md at v0.5.2 · View on GitHub · Markdown

On this page
  1. Quickstart (clean Ubuntu 24.04 + Docker)
  2. Hosts
  3. Production compose
  4. Environment reference
  5. Backup and restore
  6. Upgrades and rollback
  7. Third-party modules
  8. Image tags
  9. TLS
  10. Custom domains
  11. Abuse and takedowns
  12. Public gallery
  13. Publish approval
  14. The rehearsal (task selfhost:rehearsal)
  15. Development: task dev:tls

drobek is one image (ghcr.io/freema/drobek) plus Postgres, Redis and Caddy. Caddy terminates TLS for the dashboard and for every app host, and proxies everything to drobek on the internal network. This guide takes a clean server to a working instance — dashboard over TLS, an agent connected over MCP, a published app — and covers backups, upgrades and every setting.

Measured: the whole quickstart below (init → TLS dashboard → user → MCP → published app with an uploaded file) took 33 s in the local rehearsal (task selfhost:rehearsal, tls internal, image already built), a backup 7 s, a restore on a second "machine" (fresh checkout + task selfhost:init + task restore) 33 s; the image build itself 145 s (a VPS pulls it instead). Local = macOS, Docker Desktop, arm64, 2026-09-23.

Quickstart (clean Ubuntu 24.04 + Docker)

What you need:

Every command runs as root (or prefix sudo).

1. Docker, git and go-task

curl -fsSL https://get.docker.com | sh
apt-get install -y git openssl
snap install task --classic
docker compose version     # → Docker Compose version v2.x (or newer)
task --version             # → Task version: v3.x

2. The drobek files (the compose file, the scripts, the env template — the image itself comes from GHCR)

git clone https://github.com/freema/drobek /opt/drobek
cd /opt/drobek
git checkout "$(git tag -l 'v*' --sort=-v:refname | head -n 1)"   # the newest release (skip before the first one)

3. Configuration — generates every secret, writes .env.production (mode 600), renders deployments/Caddyfile with the image's own generator (no Node on the host):

DOMAIN=drobek.example.com APPS_DOMAIN=apps.example.net \
TLS_ACME_EMAIL=you@example.com SUPERADMIN_EMAIL=you@example.com \
SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=no-reply@example.com \
EMAIL_FROM=no-reply@example.com \
task selfhost:init

Expected output (abridged):

✓ created .env.production from .env.production.example (mode 600)
✓ generated POSTGRES_PASSWORD
✓ generated DROBEK_MASTER_KEY
✓ generated TLS_ASK_TOKEN
✓ dashboard https://drobek.example.com · apps https://<slug>.apps.example.net
✓ TLS mode for the app hosts: on-demand
✓ rendered deployments/Caddyfile (on-demand)
✓ docker compose config: OK

Then put the SMTP password in (never on the command line):

nano .env.production       # SMTP_PASS='…'   (single quotes if it has $, # or spaces)

task selfhost:init never asks anything and never overwrites a secret; run it again whenever you like (after changing TLS settings: then task tls:reload). The TLS default for a real domain is on-demand (one Let's Encrypt certificate per app host, gated by drobek); TLS_MODE=wildcard-file or TLS_MODE=dns pick a wildcard certificate instead — see "TLS" in docs/SELF-HOSTING.md.

4. Start

docker compose --env-file .env.production -f docker-compose.production.yaml up -d --wait

The first start pulls the images and drobek applies every database migration. Expected: Container drobek-prod-postgres-1 Healthy, …-redis-1 Healthy, …-drobek-1 Healthy, …-caddy-1 Healthy.

curl -s https://drobek.example.com/healthz      # → {"ok":true,"db":"up","redis":"up"}
curl -s https://drobek.example.com/api/version  # → {"name":"drobek","sha":"<commit>","version":"vX.Y.Z","commitTime":"…","startedAt":"…","modules":[…]}

Tip: alias dc='docker compose --env-file .env.production -f docker-compose.production.yaml' — the rest of this guide spells the command out.

5. Sign in — open https://drobek.example.com, enter your SUPERADMIN_EMAIL, type the 6-digit code from the e-mail. You land on /me with a personal workspace.

6. Connect an agent (Claude Code)

claude mcp add --transport http drobek https://drobek.example.com/mcp

Claude Code discovers drobek's OAuth server, opens the consent page in your browser (read, write, publish) and gets a token bound to you. Without a browser on the agent's machine, mint an API key on the server instead and pass it as a header:

docker compose --env-file .env.production -f docker-compose.production.yaml exec drobek \
  node node_modules/@drobek/oauth/dist/cli/api-key-create.js \
  --email you@example.com --name laptop --scopes read,write,publish
# → drk_…   (shown once)
claude mcp add --transport http drobek https://drobek.example.com/mcp \
  --header "Authorization: Bearer drk_…"

7. Publish an app — ask the agent: "Build a tip calculator on drobek and publish it." It calls create_app → write_files → publish; open the published_url it returns (https://tip-calculator.apps.example.net). With on-demand TLS the very first request to a new app host waits a few seconds for its certificate.

A test box without DNS — the same steps with Caddy's local CA:

DOMAIN=localhost SUPERADMIN_EMAIL=you@example.com SMTP_HOST=… task selfhost:init   # → TLS mode internal
docker compose --env-file .env.production -f docker-compose.production.yaml up -d --wait
docker compose --env-file .env.production -f docker-compose.production.yaml cp \
  caddy:/data/caddy/pki/authorities/local/root.crt ./drobek-root.crt
curl --cacert drobek-root.crt https://localhost/healthz

Trust drobek-root.crt in your browser / OS to use it without warnings (Node clients: NODE_EXTRA_CA_CERTS=drobek-root.crt). App hosts are https://<slug>.apps.localhost, which browsers resolve to the machine itself. HTTPS_PORT=8443 (plus HTTP_PORT=8080) moves Caddy off 443 — every URL then carries the port.

Hosts

Host What Example
PUBLIC_APP_URL dashboard, OAuth server, MCP at /mcp https://drobek.example.com
<slug>.<APPS_DOMAIN> an app's published version https://shop.apps.example.com
<slug>--preview.<APPS_DOMAIN> the working copy (newest version that compiled) https://shop--preview.apps.example.com
<slug>--v<N>.<APPS_DOMAIN> exactly version N https://shop--v3.apps.example.com
a verified custom domain the app's published version (Custom domains) https://shop.example.org

DNS: an A/AAAA record for the dashboard host and a wildcard *.<APPS_DOMAIN> record, both pointing at the server. The dashboard may sit on the apex of APPS_DOMAIN (drobek.app + *.drobek.app) — it never serves an app — but a separate registrable domain for the apps is the safer choice.

Production compose

docker-compose.production.yaml runs drobek, postgres 17, redis 7 and caddy, all restart: unless-stopped with a healthcheck each. Only Caddy publishes ports (80, 443, 443/udp — HTTP_PORT / HTTPS_PORT / PUBLISH_IP move them); drobek, postgres and redis stay on the internal network. Nothing secret is written in the file — every value comes from .env.production (--env-file for interpolation, env_file for drobek). A missing secret or host stops docker compose before anything starts (${VAR:?}); a missing mail transport (SMTP_HOST, or RESEND_API_KEY with EMAIL_TRANSPORT=resend) stops drobek itself at start, and docker compose --env-file .env.production -f docker-compose.production.yaml config prints no warnings. The compose project is drobek-prod (not drobek, the dev stack's name in a checkout — a down -v here can never reach the dev volumes).

.env.production.example documents every variable (what it is, how it is generated, which ones are secrets). The compose file fixes, for drobek: NODE_ENV=production, TRUST_PROXY=x-real-ip, APPS_URL_SCHEME=https, FILES_DIR=/data/files, ASSETS_DIR=/data/assets, DATABASE_URL / REDIS_URL of the bundled services, PUBLIC_ORIGIN defaulting to PUBLIC_APP_URL, and DROBEK_MODULES defaulting to all six built-ins.

Variable Required What
DROBEK_IMAGE_TAG — (latest) image tag, see Image tags
PUBLIC_APP_URL yes https://<dashboard host>[:<HTTPS_PORT>]
APPS_DOMAIN yes apps live on *.<APPS_DOMAIN> (:<port> when not 443)
POSTGRES_PASSWORD yes, secret generated; only used when pg_data is first created
DROBEK_MASTER_KEY yes, secret generated, 64 hex; encrypts upstream secrets, signs app cookies — keep it with your backups
TLS_ASK_TOKEN secret generated; the on-demand TLS ask token (drobek + Caddy)
SMTP_HOST yes (smtp) SMTP server; SMTP_PORT (587), SMTP_SECURE (0 / 1 = implicit TLS), SMTP_USER, SMTP_PASS, EMAIL_FROM
EMAIL_TRANSPORT / RESEND_API_KEY — (smtp) / secret resend sends through the Resend API instead of SMTP (then SMTP_* is not needed and RESEND_API_KEY is)
SUPERADMIN_EMAIL recommended your sign-in e-mail(s), super-admin over every workspace
LANDING_URL — your own website: <PUBLIC_APP_URL>/ answers 301 there instead of the built-in landing page
DOCS_URL — a website with the drobek docs: the agent docs link <DOCS_URL>/<page> instead of the files on GitHub
DASHBOARD_GITHUB_STARS — (on) off = the dashboard footer makes no call to api.github.com for the repository's star count
TLS_*, CADDY_* per TLS path see TLS
HTTP_PORT, HTTPS_PORT, PUBLISH_IP — published ports / bind address
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET — optional Google sign-in
TLS_CUSTOM_DOMAINS, DOMAINS_MAX_PER_APP, DOMAINS_DNS_SERVERS, DOMAINS_RECHECK_INTERVAL_MS — custom domains (catch-all certificate on by default in on-demand mode; 3 per app)
TERMS_URL, ABUSE_REPORTS_PER_IP_HOUR, ABUSE_BRAND_WORDS — abuse handling (terms link of the 451 page; 5 reports / IP / hour; publish-heuristic brand words)
GALLERY_ENABLED, GALLERY_API_PER_IP_MINUTE, GALLERY_OPENS_PER_IP_HOUR, GALLERY_LIKES_PER_USER_HOUR, GALLERY_FRAME_ANCESTORS, DUPLICATES_PER_USER_HOUR — (off) the public gallery (true = owners may list published apps; GET /api/public/gallery; 60 requests / IP / minute; 60 counted opens / IP / hour; 30 likes / account / hour; your gallery website's origins that may show listed apps as live previews and receive visitors back after a like; 10 copies of gallery apps per person per hour)
PUBLISH_APPROVAL, OPERATOR_EMAIL, PUBLISH_NOTIFY — (open, off) publish approval (approval = a workspace publishes only after a super-admin allowed it; the contact refused users see; first / every = e-mail the operator about publishes)
EMAIL_SIGNIN_APP_HOURLY_SHARE — (25) one app's percent of the sign-in e-mail budget — raise it on a single-app server (see Production compose)
EMAIL_WORKSPACE_HOURLY_SHARE — (50) one workspace's percent of each module e-mail budget — raise it to 100 on a single-workspace server
limits (OTP_*, COMPILE_*, DATA_*, FILES_*, EMAIL_*, …) — production defaults; every variable is in the Environment reference

The file is read by docker compose and by docker run --env-file (the Caddyfile generator): one KEY=value per line, no inline comments; quote a value that contains $, # or spaces with single quotes. Careful: docker compose lets a variable exported in your shell override the same key in --env-file — don't export drobek settings in the shell you run compose from. The task commands (selfhost:*, backup, restore, tls:reload) go through scripts/selfhost-compose.sh, which removes every key of .env.production from the environment first, so the file always wins there.

The compose file sets TRUST_PROXY=x-real-ip for drobek: behind Caddy the client IP (every per-IP rate limit) comes only from the X-Real-IP header Caddy sets from the TCP peer — a client-sent X-Real-IP or X-Forwarded-For is overwritten/ignored. Leave TRUST_PROXY unset only when a different proxy (e.g. nginx with X-Real-IP $remote_addr) is in front. Per-IP limits need a resolved client IP: a request that arrives without a trusted header (a proxy that does not set X-Real-IP, a request that bypassed the proxy) gets no per-IP bucket at all — not a shared one — so only the per-app, per-user and per-code limits hold it. drobek logs one rate_limit_no_client_ip warning per limit (once per start) when that happens; seeing it in production means the proxy header is missing.

Platform modules (the backends apps use through import { drobek } from 'drobek') are enabled with DROBEK_MODULES (comma-separated; a short name x loads the package drobek-module-x from the server's dependencies, or from DROBEK_MODULES_DIR for a module you installed with task selfhost:module:add — Third-party modules). The server applies each module's migrations on start and refuses to start on a module it cannot load. Limits come from their env vars or, with LIMITS_PROVIDER_URL + LIMITS_PROVIDER_SECRET, from your own signed limits endpoint. The image ships the built-in auth, email, forms, data, proxy and files (DROBEK_MODULES=auth,email,forms,data,proxy,files, the compose default; forms requires email). Proxy upstreams may only use ports 80 and 443 (PROXY_ALLOWED_PORTS); an upstream on a private address needs its hostname on PROXY_ALLOWED_HOSTS (keep it empty in production). The old /<ws>/api/proxy/<name>/* dashboard-host route is gone: an app calls /__drobek/v1/proxy/<name>/* once the upstream is assigned to it. files stores end-user uploads on disk under FILES_DIR (/data/files, the files_data volume): one file per distinct content, the type sniffed from the bytes, at most FILES_MAX_BYTES (10 MiB) per file and FILES_QUOTA_PER_APP (500 MiB) per app. Enabling data on a server that stored records through the pre-module Data API imports them (collections → the app's data config, access modes → rules, live documents → records) and drops the old collections / app_documents tables in its first migration — back up the database first. An app's preview and production hosts share its records. Module e-mail uses the same SMTP settings as the dashboard login and is capped server-wide by EMAIL_GLOBAL_HOURLY_MAX (default 500 recipients per hour). End users' sign-in codes get a reserved part of it, EMAIL_SIGNIN_HOURLY_MAX (default 20 % of the cap, at least 50, at most half — 100 of 500), one app at most EMAIL_SIGNIN_APP_HOURLY_SHARE percent of those (default 25, at least 10 — raise it on a single-app server); notifications (forms, notifyAdmins) get the rest, and one app at most EMAIL_APP_HOURLY_SHARE percent of that (default 25); one workspace (all its apps) at most EMAIL_WORKSPACE_HOURLY_SHARE percent of each (default 50 — raise it to 100 on a single-workspace server). Past its budget a class pauses for exactly EMAIL_GLOBAL_PAUSE_MINUTES, then starts a fresh hourly budget — notifications pausing never blocks sign-in — and the log gets an email_global_pause ALERT line (with class) — alert on it. The contract and the provider protocol are in MODULES.md.

Volumes (named drobek-prod_<name>):

Volume Holds In task backup
pg_data the database: apps, every version's files (content-addressed blobs), users, keys, module data yes (pg_dump -Fc)
files_data the files module's uploads (/data/files; mod_files rows point at them) yes (tar)
assets_data app assets — video, audio, images, fonts served at /<path> (/data/assets; app_assets rows point at them) yes (tar)
modules_data modules you installed (/data/modules = DROBEK_MODULES_DIR: one directory per module + modules.lock.json, Third-party modules) yes (tar)
caddy_data ACME account, issued certificates, Caddy's local CA — losing it means re-issuing every certificate yes (tar)
caddy_config Caddy's autosaved config (rebuilt from the Caddyfile) no
redis_data sessions, caches, rate limits, leases, un-flushed request counters (AOF) no — after a restore everyone signs in again

Environment reference

Every variable drobek, its compose files and its tests read. The production compose file sets the ones marked (compose) itself; everything else is optional unless the table says otherwise, and every limit has a production default. The same variables, with longer comments, are in .env.example (the dev stack) and .env.production.example (self-host). A limit marked (plan) can also come per workspace from the limits provider.

Hosts, image and ports

Variable Default What
PUBLIC_APP_URL dev http://localhost:3041 required — the dashboard origin: OAuth issuer, dashboard, MCP at /mcp. Never serves an app
PUBLIC_MCP_URL PUBLIC_APP_URL + /mcp the MCP resource identifier (the token audience, RFC 8707)
PUBLIC_ORIGIN PUBLIC_APP_URL invite links and the Google redirect_uri base
APPS_DOMAIN dev apps.localhost:3041 required in production — apps live on *.<APPS_DOMAIN> (host[:port], no scheme)
APPS_URL_SCHEME http for *.localhost, else https (compose: https) scheme of the app URLs drobek hands out
APPS_UNKNOWN_HOST_LIMIT / APPS_UNKNOWN_HOST_WINDOW_MS 60 / 60000 "no app here" answers per client IP per window, then 429
APPS_MODULE_BODY_TIMEOUT_MS 120000 a /__drobek/* request (module routes, uploads, the beacon) must deliver its body within it, else 408; raise it with FILES_MAX_BYTES for big uploads over slow links
DROBEK_IMAGE_TAG latest image tag of the production compose (Image tags)
HTTP_PORT / HTTPS_PORT / PUBLISH_IP 80 / 443 / all ports and bind address Caddy publishes
TRUST_PROXY auto (compose: x-real-ip) which client-IP header is trusted: x-real-ip = only Caddy's X-Real-IP; unset = X-Real-IP, else the rightmost X-Forwarded-For hop
NODE_ENV (compose: production) production turns on __Host- cookies and the fail-closed secret checks, and ignores the dev-only switches below
PORT 3000 the port drobek listens on inside the container (the dev compose maps WEB_PORT to it)

Secrets and TLS

Variable Default What
DROBEK_MASTER_KEY — required, secret — 64 hex; encrypts module and upstream secrets, keys the app-access cookie and the forms token. Keep it with your backups
POSTGRES_PASSWORD — required, secret (production compose) — used when pg_data is first created
TLS_ASK_TOKEN — secret, ≥ 32 URL-safe characters — the on-demand TLS ask token (drobek + Caddy); unset = every certificate refused
TLS_INTERNAL — 1 = Caddy's local CA for every site (a test box, task dev:tls)
TLS_WILDCARD_CERT_FILE / TLS_WILDCARD_KEY_FILE / TLS_CERTS_DIR — / — / ./certs TLS path (a): your wildcard certificate files
TLS_DNS_PROVIDER / TLS_DNS_PROVIDER_ARGS / TLS_DNS_CHALLENGE_OVERRIDE_DOMAIN — TLS path (b): ACME DNS-01
CADDY_IMAGE / CADDY_BUILD_TARGET / CADDY_DNS_MODULE caddy:2-alpine / — / — the DNS-01 Caddy build (drobek-caddy:dns, dns, github.com/caddy-dns/<provider>)
TLS_ACME_EMAIL — ACME account e-mail for expiry notices
TLS_CUSTOM_DOMAINS on in on-demand mode 1 / 0 — the on-demand catch-all for verified custom domains

Sign-in, e-mail and the operator

Variable Default What
SUPERADMIN_EMAIL — comma-separated sign-in addresses with super-admin rights over every workspace (the abuse queue, reports)
EMAIL_TRANSPORT smtp how all mail goes out (sign-in codes, invites, module mail): smtp or resend; an unknown value stops the server at start
SMTP_HOST / SMTP_PORT / SMTP_SECURE / SMTP_USER / SMTP_PASS / EMAIL_FROM — / 587 / 0 / — / — / — SMTP_HOST required with smtp (production refuses to start without it) — the SMTP server for sign-in codes and module mail (SMTP_SECURE=1 = implicit TLS); EMAIL_FROM is the sender for both transports (Name <address>; a bare address is sent under the name drobek)
RESEND_API_KEY — required with resend, a secret (the server refuses to start without it; it is never logged or shown) — mail goes to POST https://api.resend.com/emails with a 10 s timeout; EMAIL_FROM must be on a domain verified in Resend
OTP_IP_SHORT_LIMIT / OTP_IP_DAILY_LIMIT 5 per 15 min / 20 per 24 h dashboard sign-in codes sent per client IP
OTP_EMAIL_HOURLY_LIMIT / OTP_EMAIL_COOLDOWN_MS 3 per hour / 60000 codes per address, minimum gap per address
OTP_GLOBAL_HOURLY_MAX 100 codes per hour server-wide, then sending pauses
OTP_VERIFY_IP_LIMIT / OTP_VERIFY_IP_WINDOW_S 30 / 900 code checks per client IP per window (the per-code cap of 5 guesses always applies)
OTP_LOGIN_DISABLED 0 1 = kill switch: no sign-in codes are sent
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET — optional Google sign-in for the dashboard (redirect URI <PUBLIC_ORIGIN>/auth/google/callback)
GOOGLE_AUTH_URL / GOOGLE_TOKEN_URL / GOOGLE_USERINFO_URL Google's endpoints dev only: point Google sign-in at the mock provider (task mock:google)
OAUTH_DCR_MAX_UNUSED_CLIENTS 500 MCP clients registered by DCR that never got consent, before registration answers 503
OAUTH_CIMD_DEV_ORIGINS — dev/test only, ignored in production: origins allowed to serve a Client ID Metadata Document over plain http
DASHBOARD_GITHUB_STARS on the dashboard footer shows the source repository's GitHub star count, fetched server-side from api.github.com (unauthenticated, 3 s timeout, cached 1 h, never delays a page); off = no outbound call, no stars

Apps, compiler and serving

Variable Default What
COMPILE_MAX_FILES / COMPILE_MAX_FILE_BYTES / COMPILE_MAX_TOTAL_BYTES 200 / 524288 / 5242880 per app version
COMPILE_MAX_IMPORT_DEPTH 50 depth of a relative import chain
COMPILE_TIMEOUT_MS / COMPILE_CONCURRENCY / COMPILE_QUEUE_TIMEOUT_MS 10000 / 4 / 10000 per build; builds at once; max queue wait (then busy)
BEACON_RATE_LIMIT / BEACON_APP_RATE_LIMIT / BEACON_RATE_WINDOW_MS 60 / 600 / 60000 browser error reports per app+IP and per app per window
BEACON_MAX_EVENTS_PER_APP / BEACON_RETENTION_DAYS / BEACON_SAMPLE_RATE 500 / 30 / 1 the per-app error buffer (newest N, max age) and sampling
LOGS_PRUNE_INTERVAL_MS 3600000 how often the server removes get_logs rows past their retention for every app (errors past the buffer above, compiles and daily request stats older than 30 days)
DROBEK_MIGRATE_ON_START 1 0 = the server does not apply migrations on start (tests, tooling)
AUDIT_RETENTION_DAYS 365 audit rows older than this are pruned daily
APPS_MAX_PER_WORKSPACE 50 live apps per workspace (deleted ones do not count); create_app beyond it answers limit_exceeded (plan)
ASSETS_DIR /data/assets (compose) app asset storage (the assets_data volume)
APP_ASSET_MAX_BYTES / APP_ASSETS_QUOTA 104857600 / 1073741824 one app asset (100 MiB) / all assets of one app (1 GiB); asset_too_large / asset_quota_exceeded (plan). An upload must arrive within Node's 300 s request timeout
APP_ASSET_UPLOADS_PER_HOUR 60 upload URLs (create_asset_upload, the Assets tab) per app per hour, then rate_limited
APP_FRAME_SRC_EXTRA — extra https://host[:port] origins (comma or space separated) every app may show in an <iframe>, besides YouTube, Vimeo and Google Drive; an invalid entry stops the server at start

Platform modules

Variable Default What
DROBEK_MODULES none (compose: auth,email,forms,data,proxy,files) the modules this server runs; x loads drobek-module-x (MODULES.md)
DROBEK_MODULES_ROOT the server's directory where module packages are resolved from when they are not in DROBEK_MODULES_DIR
DROBEK_MODULES_DIR /data/modules (compose: the modules_data volume; dev: ./.modules) modules the operator installed (task selfhost:module:add, dev: task module:add): <dir>/<name>/node_modules/<package> + modules.lock.json; looked up BEFORE the server's dependencies; a module there that the lockfile does not list, or whose files changed, refuses the start (Third-party modules). Change it only for a derived image that bakes its modules elsewhere
DROBEK_MODULES_UNLOCKED — 1 = load modules from DROBEK_MODULES_DIR without the modules.lock.json check — for developing a module locally; ignored (with a warning) when NODE_ENV=production
DROBEK_MODULE_<NAME>_DEFAULTS (e.g. DROBEK_MODULE_AUTH_DEFAULTS) — server-wide config defaults of the module <name>: a JSON merge patch over its defaults ({"allow":{"domains":["acme.com"]}}), validated by its schema at start — invalid refuses the start (MODULES.md)
MODULE_ENABLED_<NAME> (e.g. MODULE_ENABLED_CRM) 0 only for an opt-in module (availability: 'opt-in'): 1 enables it on every workspace; unset / 0 = a super-admin enables it per workspace in the dashboard (Workspace → Modules). A limits provider may answer it per workspace (1 on, 0 off — also over the dashboard switch) (MODULES.md) (plan)
DROBEK_SKILLS_DIR ./skills (image: /app/skills) the general skills skill_info lists
LIMITS_PROVIDER_URL / LIMITS_PROVIDER_SECRET — per-workspace limits from your own HMAC-signed endpoint (secret ≥ 32 characters)
AUTH_CODES_PER_IP_15MIN / AUTH_CODES_PER_IP_DAY 5 / 20 auth: sign-in codes per client IP (plan)
AUTH_CODES_PER_EMAIL_HOUR / AUTH_CODES_PER_APP_HOUR 3 / 100 auth: codes per address, per app (plan)
AUTH_ATTEMPTS_PER_IP_15MIN / END_USERS_MAX_PER_APP 30 / 1000 auth: send-code, verify and provider begin/complete calls per IP; end users per app (plan)
AUTH_PROVIDER_CALLBACKS_PER_IP_15MIN 60 auth: sign-in provider callbacks (/__drobek/auth/callback/<provider> on the dashboard host) per client IP per 15 min — server-wide, never a plan value (the app is not known yet)
EMAIL_PER_APP_PER_DAY / EMAIL_NOTIFY_ADMINS_PER_DAY 50 / 20 email: notification mails per app per day; notifyAdmins() per user per day (plan)
EMAIL_GLOBAL_HOURLY_MAX / EMAIL_GLOBAL_PAUSE_MINUTES 500 / 15 the operator-wide cap on all module mail (recipients per hour) and the pause length (a fixed window: the class budget restarts after it)
EMAIL_SIGNIN_HOURLY_MAX / EMAIL_SIGNIN_APP_HOURLY_SHARE 20 % of the cap (at least 50, at most half) / 25 % the sign-in part of the cap; one app's share of it
EMAIL_APP_HOURLY_SHARE 25 % one app's share of the notification part
EMAIL_WORKSPACE_HOURLY_SHARE 50 % one workspace's share (all its apps) of the notification and of the sign-in part; never below one app's share
FORMS_SUBMITS_PER_IP_HOUR / FORMS_PER_APP_PER_DAY 10 / 200 forms (plan)
DATA_MAX_DOCS_PER_APP / DATA_MAX_DOC_BYTES / DATA_MAX_BYTES_PER_APP 10000 / 102400 / 52428800 data: records, bytes per record, bytes per app (plan)
DATA_WRITE_RATE_LIMIT / DATA_WRITE_RATE_WINDOW_MS 120 / 60000 data: writes per app per window (plan)
DATA_WRITES_PER_PRINCIPAL_PER_MIN 60 data: writes per minute of one signed-in user (or one visitor IP), checked before the per-app limit (plan)
FILES_DIR /data/files files: upload storage (the files_data volume)
FILES_MAX_BYTES / FILES_QUOTA_PER_APP / FILES_UPLOAD_RATE_LIMIT 10 MiB / 500 MiB / 60 per min files (plan)
FILES_UPLOADS_PER_PRINCIPAL_PER_MIN 20 files: uploads per minute of one signed-in user (or one visitor IP), checked before the per-app limit (plan)
FILES_SWEEP_INTERVAL_MS / FILES_SWEEP_RETENTION_MS 3600000 / 86400000 files: how often the sweep runs; it removes the uploads of apps deleted that long ago, temp uploads untouched that long and blobs that old no app references
PROXY_ALLOWED_PORTS / PROXY_ALLOWED_HOSTS 80,443 / empty proxy: upstream ports; hostnames whose private IPs may be reached (keep empty)
PROXY_CONNECT_TIMEOUT_MS / PROXY_MAX_RESPONSE_BYTES 8000 / 5242880 proxy: per upstream request (the size cap also holds for a decoded gzip/br body)
PROXY_MAX_CONCURRENT / PROXY_MAX_CONCURRENT_PER_APP 32 / 8 proxy: upstream calls in flight on the whole server / per app; over either → 429 proxy_busy
PROXY_CALLS_PER_MIN / PROXY_PUBLIC_CALLS_PER_MIN_PER_IP 60 / 10 proxy: calls per app, per IP to public upstreams (plan)
HELLO_WAVES_PER_MINUTE 30 the example module drobek-module-hello
Variable Default What
DOMAINS_MAX_PER_APP 3 custom domains per app, pending + verified; 0 = custom domains off (plan)
DOMAINS_DNS_SERVERS the system resolver comma-separated resolver IPs for verification
DOMAINS_RECHECK_INTERVAL_MS 3600000 how often the re-check sweep runs
DOMAINS_DNS_MOCK — dev/test only, ignored in production: redis answers lookups from Redis keys
TERMS_URL <PUBLIC_APP_URL>/terms linked from the 451 page of a taken-down app
LANDING_URL — (the built-in landing page) <PUBLIC_APP_URL>/ answers 301 to this URL — for an operator whose website lives elsewhere
DOCS_URL — (the Markdown files in the GitHub repository) the base of a website with the drobek docs, each page at <DOCS_URL>/<slug> (overview, agent, modules, self-hosting, architecture, security, licensing) with a Markdown twin at <DOCS_URL>/<slug>.md: /llms.txt links the .md pages, /llms-full.txt the agent guide's .md, /build-with-your-agent and the landing page the agent guide. Not an http(s) URL (or one with a query or fragment) stops the server at start
ABUSE_REPORTS_PER_IP_HOUR 5 valid abuse reports per client IP per hour
ABUSE_BRAND_WORDS a built-in list the publish heuristic's brand words (comma-separated)
GALLERY_ENABLED off true = the public gallery: owners (and, on their explicit yes, their agents) may list published apps; GET /api/public/gallery answers. Off = no switch in the dashboard, the endpoint answers 404
GALLERY_API_PER_IP_MINUTE 60 requests to GET /api/public/gallery per client IP per minute (429 over it)
GALLERY_OPENS_PER_IP_HOUR 60 visits through a gallery openUrl counted per client IP per hour (more still redirect, uncounted)
GALLERY_LIKES_PER_USER_HOUR 30 likes and unlikes per account per hour on /gallery/like/<slug> (429 over it)
GALLERY_FRAME_ANCESTORS — (no embedding) space-separated bare http(s)://host[:port] origins (at most 10) of your gallery website that may show a listed app in an <iframe> — added to frame-ancestors only on the production host (and custom domains) of an app the gallery shows, only while GALLERY_ENABLED; a wildcard, a path or a quote stops the server at start (see Public gallery)
DUPLICATES_PER_USER_HOUR 10 copies of gallery apps one person may make per hour, from the dashboard's /duplicate/<slug> and the MCP tool duplicate_app together (see Public gallery)
PUBLISH_APPROVAL open open = every workspace may publish unless a super-admin blocked it; approval = a workspace publishes only after a super-admin allowed it (or when a super-admin is its member) — see Publish approval. Any other value, or approval without SUPERADMIN_EMAIL, stops the server at start
OPERATOR_EMAIL the SUPERADMIN_EMAIL addresses one address: the contact a refused publish names, the recipient of approval requests and publish notifications (without it every super-admin is e-mailed and the first one is shown), and an extra recipient of abuse reports; not one e-mail address = no start
PUBLISH_NOTIFY off e-mail the operator (OPERATOR_EMAIL, else every super-admin) about publishes: first = the first publish of each app, every = every publish, at most one e-mail per app per hour; a super-admin's own publishes are never e-mailed. Any other value stops the server at start

Development and tests only

Variable Default What
WEB_PUBLISH / POSTGRES_PUBLISH / REDIS_PUBLISH / MAILPIT_PUBLISH 3041 / 5441 / 6391 / 8025 host ports of the dev stack
WEB_PORT 3000 drobek's listen port inside the dev container
DATABASE_URL / REDIS_URL the dev stack on localhost (compose: the bundled services) datastores; host-side for tools and tests
GIT_SHA dev the commit in /api/version and the footer (task dev and image builds set it)
BASE_URL_WEB / BASE_URL_MCP http://localhost:3041 e2e targets
TEST_ENV — local = the e2e may use the local datastores
ALLOW_DESTRUCTIVE — 1 (+ a local DATABASE_URL host) lets the e2e global setup truncate tables

Backup and restore

task backup
# ✓ backups/drobek-20260923T201500Z.tar.gz — 1234567 bytes in 4 s
#   apps 12 · files 40 · assets 3 · core migrations 20 · image ghcr.io/freema/drobek:v1.2.0 (v1.2.0 abc1234)

One archive (mode 600, in backups/, override with BACKUP_DIR=): db.dump (pg_dump -Fc of the whole database — one consistent snapshot), files.tar (the files_data volume), assets.tar (the assets_data volume), modules.tar (the modules_data volume), caddy_data.tar, SHA256SUMS and a manifest.json with the image tag / id / version / commit, the checkout's commit, a fingerprint of DROBEK_MASTER_KEY, row counts and the size + sha256 of every part. It runs online: postgres is started if it is not running, nothing else is touched; the uploads and assets are archived after the dump, so every file and asset row in the dump finds its bytes (only one deleted or replaced in between can be missing — stop drobek first for a quiesced backup). Schedule it with cron and copy the archives off the machine:

15 3 * * * cd /opt/drobek && task backup >> /var/log/drobek-backup.log 2>&1

Not in the archive: .env.production — it holds DROBEK_MASTER_KEY, without which the restored upstream secrets (proxy module) cannot be decrypted. Keep a copy of it somewhere safe, separately from the backups. Redis is not backed up (sessions, caches, rate-limit counters).

Restore into a stack whose database is empty — a new machine, or this one after docker compose … down -v:

# on the new machine: steps 1–2 of the quickstart (the same or a newer release), then
scp old-server:/opt/drobek/.env.production /opt/drobek/.env.production   # the SAME secrets
task selfhost:init                                     # renders the Caddyfile, keeps every secret
task restore BACKUP=backups/drobek-20260923T201500Z.tar.gz
# ✓ …verified — created 2026-09-23T20:15:00Z, image ghcr.io/freema/drobek:v1.2.0 (v1.2.0 abc1234)
# ✓ restored in 25 s — /healthz {"ok":true,"db":"up","redis":"up"}
#   apps 12 · files 40 (backup: apps 12 · files 40)

task restore verifies the checksums, refuses a DROBEK_MASTER_KEY that does not match the backup's fingerprint (ALLOW_KEY_MISMATCH=1 restores anyway, without usable upstream secrets), refuses a non-empty database (FORCE=1 drops and recreates it — back it up first), stops drobek and caddy, restores the database, replaces files_data, assets_data (left empty when the archive has no assets.tar) and caddy_data, and starts the stack (up -d --wait). Restore with the backup's image version or a newer one (image_version in manifest.json) — a newer image migrates the restored database forward on start; an older one does not know its migrations. Point the DNS records at the new machine; the restored caddy_data carries the certificates over. Sessions (dashboard users and apps' end users) live in Redis, which is not in the backup: after a restore on a new machine everyone signs in again; API keys and OAuth clients are in the database and keep working. A FORCE=1 restore on the same machine leaves Redis as it is.

Upgrades and rollback

The server applies pending migrations itself on every start (the core journal drizzle.__drizzle_migrations_core, then one __drizzle_migrations_mod_<name> per module). An upgrade still runs them as their own step first, so a failing migration stops the upgrade while nothing new is serving:

cd /opt/drobek
git fetch --tags && git checkout vX.Y.Z      # the compose file + scripts of the new release
sed -i 's/^DROBEK_IMAGE_TAG=.*/DROBEK_IMAGE_TAG=vX.Y.Z/' .env.production   # pin it (or keep latest)
task selfhost:upgrade

task selfhost:upgrade is exactly:

task backup                                                   # the rollback point
docker compose --env-file .env.production -f docker-compose.production.yaml pull --ignore-buildable
docker compose --env-file .env.production -f docker-compose.production.yaml pull caddy     # (DNS-01 Caddy: build --pull caddy)
docker compose --env-file .env.production -f docker-compose.production.yaml up -d --wait postgres redis
docker compose --env-file .env.production -f docker-compose.production.yaml stop drobek
task selfhost:migrate     # the new image: applies the release's migrations, exits
task selfhost:migrate     # again: "migrations: nothing to apply (up to date)"
docker compose --env-file .env.production -f docker-compose.production.yaml up -d --wait

task selfhost:migrate is docker compose … run --rm --no-deps -T drobek node dist/server/migrate.js: the server's start-up checks, then every migration, without listening. Running migrations twice (and every later start) is safe: drizzle records each applied migration in its journal table inside the same transaction as the migration itself, so a second run finds everything recorded and applies nothing — the second migrate is the proof that the first one completed, and the up -d that follows migrates nothing. A migration that fails rolls back its transaction and leaves the journal as it was; the old container is already stopped, so fix the cause (or roll back) before starting.

Rollback. previous is the release that was latest before the newest one — but it moves with the next release, so roll back to the exact version:

sed -i 's/^DROBEK_IMAGE_TAG=.*/DROBEK_IMAGE_TAG=vX.Y.W/' .env.production    # the release you came from
# the new release migrated the database? (the migrate output said "applied N")
task restore FORCE=1 BACKUP=backups/<the backup task selfhost:upgrade just took>
# it did not ("nothing to apply" on the first run too):
docker compose --env-file .env.production -f docker-compose.production.yaml up -d --wait

Migrations only go forward; an older image on a database migrated by a newer one is not supported, which is why the upgrade takes a backup first.

Check a live server end to end. The @smoke suite drives the whole MCP loop against a running server over public HTTP only: list_apps, create_app (or re-use), write_files, the preview host, publish and the production host. Give it a service identity that needs no mailbox; --create-user creates the user when that e-mail never signed in:

docker compose --env-file .env.production -f docker-compose.production.yaml exec -T drobek \
  node node_modules/@drobek/oauth/dist/cli/api-key-create.js \
  --email smoke@drobek.example.com --name smoke --scopes read,write,publish --create-user
# on the operator's machine, from a checkout of the same release:
BASE_URL_WEB=https://drobek.example.com SMOKE_API_KEY=drk_… task e2e:smoke

The smoke key always works on one app, smoke-<12 hex>, derived from the key, and publishes a new version of it on every run, so nothing piles up.

Third-party modules

A platform module that does not ship in the image (your company's, one from npm) is installed into the modules_data volume (/data/modules = DROBEK_MODULES_DIR) — no image build, no package manager in the running server:

task selfhost:module:add -- drobek-module-acme-erp@1.2.0
# · npm install drobek-module-acme-erp@1.2.0 → drobek-prod_modules_data:/data/modules/.staging-1a2b3c4d (node:22-alpine, --ignore-scripts)
# ✓ drobek-module-acme-erp@1.2.0 installed as the module "acmeerp" (contract ^1.1) → /data/modules/acmeerp
#   modules.lock.json: sha512-…
#
# Next: enable it in .env.production and restart drobek (it applies the module's migrations on start):
#   DROBEK_MODULES=auth,email,forms,data,proxy,files,drobek-module-acme-erp
#   ./scripts/selfhost-compose.sh up -d --wait drobek

The spec is anything npm install accepts: a registry version (drobek-module-acme-erp@1.2.0, @acme/drobek-module-erp@^1), a tarball URL or a local .tgz path (npm pack output; mounted read-only into the npm container), a git URL (git+https://…/x.git#v1.2.0 — the package must have its dist/ committed). add runs in two steps:

  1. npm in a throwaway container — docker run --rm node:22-alpine over the volume: npm install --prefix /data/modules/.staging-<id> --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts <spec>;
  2. the image's own installer — ./scripts/selfhost-compose.sh run --rm --no-deps drobek node node_modules/@drobek/modules/dist/cli/module-lock.js add …: the package must declare @drobek/modules as a peer dependency in a range this server satisfies; nested copies of @drobek/*, zod and drizzle-orm are deleted (the server provides them); the module is imported once for its name and checked like at start (its contract against the server's module contract); it moves to /data/modules/<name> and is recorded in modules.lock.json with the server's hashModuleTree() — the same function checks it at every start — then loaded the way the server will load it, the migration lint included. Anything failing leaves the previous install and lockfile in place.

Then put the printed DROBEK_MODULES line into .env.production (the short name for a drobek-module-<name> package, else the full package name) and restart drobek; the start applies the module's migrations and /api/version lists it with "source":"dir". The script prints this and stops: it never edits .env.production and never restarts anything.

task selfhost:module:list
# NAME     PACKAGE                 VERSION  CONTRACT  INTEGRITY           IN DROBEK_MODULES  STATUS
# acmeerp  drobek-module-acme-erp  1.2.0    ^1.1      sha512-q8vN0Lr2Xc…  yes                ok
task selfhost:module:remove -- acmeerp

list reads the lockfile and hashes every module again: changed (files edited after the install), missing (a lock entry without its directory) and unrecorded (a directory the lockfile does not list) refuse the start when DROBEK_MODULES names them — add the module again or remove it. remove deletes /data/modules/<name> and its lock entry and warns when DROBEK_MODULES still names it (take it out before drobek restarts). It never touches the database: the module's tables (mod_<name>, mod_<name>_*) and its journal drizzle.__drizzle_migrations_mod_<name> stay, so adding the module again finds its data. To drop them for good, take a task backup first, list them with the query remove prints and DROP TABLE each in psql (./scripts/selfhost-compose.sh exec postgres psql -U drobek -d drobek).

Upgrade = add with the new version (it replaces the directory and the lock entry; the output names the version it replaced), then restart drobek. Rollback = add of the old version, or task restore of the backup taken before (modules_data is part of every task backup, the lockfile with it). What the script never does: run a package's install scripts, change the image, edit .env.production or restart drobek. A module runs inside the server with the whole database — install only modules you trust (MODULES.md → Installing an external module).

Derived image

An operator with their own CI can bake the modules into an image instead — the same layout, lockfile and start-time checks, built by the image's own installer:

# Dockerfile.drobek — drobek + your modules
ARG DROBEK_TAG=vX.Y.Z
FROM node:22-alpine AS modules
RUN npm install --prefix /modules/.staging-erp --omit=dev --omit=peer --legacy-peer-deps \
      --ignore-scripts --no-audit --no-fund @acme/drobek-module-erp@1.2.0

FROM ghcr.io/freema/drobek:${DROBEK_TAG}
COPY --from=modules --chown=node:node /modules/ /opt/drobek-modules/
RUN node node_modules/@drobek/modules/dist/cli/module-lock.js add \
      --dir /opt/drobek-modules --staging .staging-erp --spec @acme/drobek-module-erp@1.2.0

One RUN npm install + module-lock.js add pair per module (the build fails on anything add refuses). Build it where the stack runs (or docker load it from your CI) under a local tag — docker build -f Dockerfile.drobek --build-arg DROBEK_TAG=vX.Y.Z -t ghcr.io/freema/drobek:vX.Y.Z-acme . — and set in .env.production: DROBEK_IMAGE_TAG=vX.Y.Z-acme, DROBEK_MODULES_DIR=/opt/drobek-modules and the DROBEK_MODULES entries. The directory is outside /data/modules on purpose: the compose file mounts the modules_data volume there, which would hide the image's copy. A local tag cannot be pulled, so an upgrade is a rebuild with the new DROBEK_TAG followed by the task selfhost:upgrade steps without pull. task selfhost:module:* refuse to run with such a DROBEK_MODULES_DIR: the image is the source of its modules.

Image tags

ghcr.io/freema/drobek (linux/amd64 only in v1 — no ARM image):

Tag What Moves?
vX.Y.Z one release, built from the git tag vX.Y.Z never
latest the newest release (the compose default) on every release
previous the release latest pointed at before the newest one on every release
edge the newest main commit that passed CI on every main push
<sha> one commit that passed CI (main or a release tag) never

A release is a pushed vX.Y.Z tag: CI runs the quality gate and the e2e suite against the image it builds from that tag (GIT_SHA = the tag's commit, VERSION = the tag, COMMIT_TIME = that commit's time, all in /api/version), pushes that exact image as vX.Y.Z, then retags in the registry: the former latest → previous, vX.Y.Z → latest. A pre-release tag (vX.Y.Z-rc.1) gets only its own tag. To rebuild a release image yourself: git checkout vX.Y.Z && task build (same sources and lockfile; the build args come from the checkout).

The same tag publishes the npm packages for module authors at its version: @freema/drobek-modules, @freema/drobek-sdk and create-drobek-module (MODULES.md → Writing a module; module code imports the first two as @drobek/modules / @drobek/sdk through npm aliases) — @freema/drobek-modules@X.Y.Z is the module contract of the image vX.Y.Z. node scripts/npm-packages.mjs pack (after pnpm build:packages) writes the same tarballs into dist-npm/.

TLS

The dashboard host always gets a normal ACME certificate (Let's Encrypt via HTTP-01/TLS-ALPN — ports 80 and 443 must be reachable). The app hosts *.<APPS_DOMAIN> use exactly one of three paths; the Caddyfile generator picks it from the environment and refuses combinations. task selfhost:init TLS_MODE=<mode> sets the variables below in .env.production and renders deployments/Caddyfile (gitignored) with the image's generator; after editing them by hand, re-run task selfhost:init and task tls:reload:

TLS_MODE= Set in .env.production Path
wildcard-file TLS_WILDCARD_CERT_FILE + TLS_WILDCARD_KEY_FILE (a) your wildcard certificate files
dns TLS_DNS_PROVIDER (+ TLS_DNS_PROVIDER_ARGS, TLS_DNS_CHALLENGE_OVERRIDE_DOMAIN) (b) wildcard via ACME DNS-01
on-demand (default for a real domain) none of them (+ TLS_ASK_TOKEN) (c) on-demand, one certificate per app host
internal (default for localhost) TLS_INTERNAL=1 a test box: Caddy's local CA for everything

TLS_ACME_EMAIL (optional) is the ACME account e-mail for expiry notices.

The generator reads .env.production, refuses ambiguous or invalid settings instead of guessing, and writes a Caddyfile that contains no secrets: the ask token is referenced as {$TLS_ASK_TOKEN} and DNS credentials as {env.NAME} placeholders, both resolved from Caddy's own environment. In a development checkout task caddy:config runs the same generator on the host (Node 22 + the built @drobek/core) from .env.

(a) Wildcard certificate files

You obtain a *.<APPS_DOMAIN> certificate yourself (any ACME client with DNS-01, or a commercial CA) and renew it yourself.

task selfhost:init TLS_MODE=wildcard-file
# .env.production now has (paths INSIDE the caddy container):
#   TLS_WILDCARD_CERT_FILE=/certs/wildcard.crt
#   TLS_WILDCARD_KEY_FILE=/certs/wildcard.key
#   TLS_CERTS_DIR=./certs      (host directory mounted read-only at /certs)

Put the full chain in certs/wildcard.crt and the key in certs/wildcard.key. After every renewal:

task tls:reload   # caddy reload --force: re-reads the config AND the certificate files

A plain caddy reload skips an unchanged config, so it would keep serving the old certificate — the task passes --force. Hook task tls:reload into your renewal tool's deploy hook. Generated app block:

*.apps.example.com {
	tls /certs/wildcard.crt /certs/wildcard.key
	import drobek
}

(b) DNS-01 with a Caddy DNS module

Caddy obtains and renews the wildcard itself over ACME DNS-01. That needs a Caddy binary with a DNS provider module, which deployments/Dockerfile.caddy builds with xcaddy:

task selfhost:init TLS_MODE=dns TLS_DNS_PROVIDER=<provider> \
  CADDY_DNS_MODULE=github.com/caddy-dns/<provider>
# .env.production now has CADDY_IMAGE=drobek-caddy:dns, CADDY_BUILD_TARGET=dns,
# CADDY_DNS_MODULE, TLS_DNS_PROVIDER and TLS_DNS_PROVIDER_ARGS={env.DNS_API_TOKEN}
# (provider-specific, placeholders only)

# .env.caddy — credentials for Caddy ONLY (drobek never sees them)
DNS_API_TOKEN=…

docker compose --env-file .env.production -f docker-compose.production.yaml build caddy

Modules exist only for some DNS hosts — check github.com/caddy-dns first. There is no Hostinger DNS module: Caddy DNS modules are built on libdns, and github.com/libdns/hostinger does not exist — a zone hosted at Hostinger cannot answer DNS-01 through Caddy directly. Use the CNAME delegation below (or path (a)).

Delegating _acme-challenge with a CNAME. When your zone's DNS host has no module, point the challenge name at a zone you keep at a provider that has one, and tell Caddy to write the TXT record there:

; in the APPS_DOMAIN zone (at the provider without a module)
_acme-challenge.apps.example.com.  CNAME  _acme-challenge.acme-delegate.example.net.
# .env.production — the delegate zone acme-delegate.example.net is hosted at <provider>
TLS_DNS_PROVIDER=<provider>
TLS_DNS_PROVIDER_ARGS={env.DNS_API_TOKEN}
TLS_DNS_CHALLENGE_OVERRIDE_DOMAIN=_acme-challenge.acme-delegate.example.net

The ACME CA follows the CNAME and finds the TXT record in the delegate zone; the credentials only ever touch that small zone. Generated app block:

*.apps.example.com {
	tls {
		dns <provider> {env.DNS_API_TOKEN}
		dns_challenge_override_domain _acme-challenge.acme-delegate.example.net
	}
	import drobek
}

(c) On-demand, one certificate per app host

With no wildcard, Caddy issues a certificate for each app host at its first TLS handshake. That is always gated: before every new certificate Caddy asks drobek, and drobek says yes only for a host of an existing app. task selfhost:init generates TLS_ASK_TOKEN (for every mode) and the compose file hands the same value to drobek and to Caddy.

{
	on_demand_tls {
		ask http://drobek:3000/api/internal/tls/ask?token={$TLS_ASK_TOKEN}
	}
}
*.apps.example.com {
	tls {
		on_demand
	}
	import drobek
}

GET /api/internal/tls/ask?domain=<host>&token=<TLS_ASK_TOKEN>:

Answer When
200 <slug>, <slug>--preview or <slug>--v<N> directly under APPS_DOMAIN, and a live, non-deleted app owns <slug> (for --v<N> the version itself is not checked)
200 a verified custom domain of a live, non-deleted app (M3-01, Custom domains)
401 missing or wrong token (compared in constant time; also accepted as the X-Drobek-Tls-Ask-Token header)
404 everything else: other hosts outside APPS_DOMAIN (unknown or not yet verified custom domains), the dashboard host, deeper names, unknown slugs — and every request while TLS_ASK_TOKEN is unset (fail closed), or one that arrives on the public dashboard host
503 the database lookup failed (no certificate)

The endpoint is internal: Caddy refuses /api/internal/* with 404 on every public site, drobek answers it only on the internal address (drobek:3000, never the public dashboard host), and only with the token. A set-but-weak TLS_ASK_TOKEN (shorter than 32 characters or not URL-safe) stops drobek from starting; the generator refuses on-demand mode without a valid one.

Caveats: the first request to a new app host waits for issuance (seconds); Let's Encrypt limits certificates per registered domain per week (see its rate-limit documentation), and each app has up to three kinds of hosts plus one per version URL you open — fine for a self-host with a handful of apps, not for a busy multi-tenant instance (use (a) or (b) there). Certificates stay cached in caddy_data after an app is deleted until they expire.

Custom domains

An app can also answer on a host name its owner controls (M3-01). The owner adds it on the app's Domains tab in the dashboard (editor or workspace-admin), creates two DNS records and clicks Verify:

Record Name Value
CNAME shop.example.org <slug>.<APPS_DOMAIN> (e.g. shop.apps.example.com)
TXT _drobek.shop.example.org drobek-verify=<token> (shown on the Domains tab)

TLS for custom domains

The generated Caddyfile carries a catch-all site for every other host name, issued on demand behind the same ask endpoint:

https:// {
	tls {
		on_demand
	}
	import drobek
}

drobek's ask answers 200 only for a verified domain of a live app, so an unknown SNI never triggers an ACME order. The catch-all is on by default in mode (c); in modes (a) and (b) set TLS_CUSTOM_DOMAINS=1 (then TLS_ASK_TOKEN is required as well — the generator refuses otherwise); TLS_CUSTOM_DOMAINS=0 turns it off. Re-run task selfhost:init + task tls:reload after changing it (task caddy:config in a development checkout).

Certificate lifecycle: Caddy obtains the certificate at the first HTTPS request after verification (HTTP-01 on port 80 or TLS-ALPN-01 on 443 — both must reach Caddy; the first request waits a few seconds) and renews it itself. Removing a domain or losing its verification stops serving it and refuses new certificates, but does not revoke the one already issued — it stays in caddy_data until it expires. Let's Encrypt's per-domain rate limits apply per customer domain.

Development: the dev compose file sets DOMAINS_DNS_MOCK=redis, which answers the lookups from Redis keys drobek:dns-mock:<txt|cname|a|aaaa>:<name> (a JSON string array; "SERVFAIL" simulates a transient failure) and admits the .test TLD. It is ignored, with a warning, when NODE_ENV=production.

Abuse and takedowns

Anyone can publish on a public drobek, so the operator (every address in SUPERADMIN_EMAIL) gets a moderation queue. Nothing is blocked automatically.

DMCA notices and the legal side of abuse handling belong to your terms of service, not to drobek.

With GALLERY_ENABLED=true the server keeps a public list of apps whose owners chose to show them. Off by default: a fresh server publishes no app list.

There are no screenshots: drobek never runs an app's code on the server.

Publish approval

Anyone can sign up on a drobek server, create workspaces, and build and preview apps. What goes on an app's public production host is decided in two places:

For one publish: a super-admin publishing is always allowed; a blocked workspace is refused; an allowed workspace, or one with a super-admin member, may publish; otherwise open allows and approval refuses. The check sits in publish() itself, so the dashboard's Publish, an agent's publish and a rollback (a publish too) share it. Previews, versions, restore, data, secrets, domains and everything else are never gated.

Blocking is the switch for an open server: people publish without waiting for you, and you turn a workspace off when it misuses that.

Approval (PUBLISH_APPROVAL=approval):

The contact is OPERATOR_EMAIL, else the first SUPERADMIN_EMAIL address. Agents see can_publish, publish_contact and the workspace's publishing state in list_apps and get_app.

Decide at /admin/publishing (super-admins only; linked from /workspaces and the moderation queue): every workspace with its state, admins, apps and live apps; filters for waiting requests, default, allowed and blocked; ?workspace=<slug> shows one. In open mode Block publishing / Unblock come first; in approval mode Approve / Revoke approval / Block publishing. Audited workspace.publish_approve, workspace.publish_revoke, workspace.publish_block and workspace.publish_unblock. A super-admin's agent can do the same with the MCP tool set_workspace_publishing({ workspace, publishing: default | allowed | blocked, user_confirmed }) (user_confirmed: true after the super-admin's explicit yes); other users never see that tool.

Publish notifications (PUBLISH_NOTIFY, off by default): first e-mails OPERATOR_EMAIL (else every super-admin) about the first publish of each app, every about every publish — at most one e-mail per app per hour. The e-mail names the app, its live URL and custom domains, the workspace, who published it from the dashboard or over MCP, the version and whether it was the first publish, a republish or a rollback, with links to the app, its dashboard page and /admin/publishing?workspace=<slug> (take the app down, block the workspace). A super-admin's own publishes are not e-mailed. The e-mail is sent after the publish and never delays or fails it.

Abuse reports go to every super-admin and to OPERATOR_EMAIL (each address once).

Upgrading approves every workspace that already had a published app (migration 0026), so switching to approval never blocks the next publish of an app that was live then; migration 0027 adds the block.

The rehearsal (task selfhost:rehearsal)

scripts/selfhost-rehearsal.sh runs this guide end to end on throwaway stacks (unique COMPOSE_PROJECT_NAMEs, every port on 127.0.0.1, a throwaway Mailpit as the SMTP server): it builds the image, copies only the self-host files into a fresh directory ("machine A"), runs task selfhost:init twice (idempotency) and docker compose config (no warnings), starts the stack, signs a user in over the e-mail code flow, mints an API key with the container CLI, creates + writes + publishes an app over MCP (the official SDK client) and uploads a file through the files module; installs a packed module with task selfhost:module:add, enables it and checks /api/version loads it from the modules directory; then task backup, down -v, a second fresh directory ("machine B") with only machine A's .env.production, task selfhost:init, task restore, and asserts the app serves on its host, the file downloads byte for byte, the same API key works, Caddy's restored CA still validates and the server starts with the same modules.lock.json and the module; a second restore must be refused and a second task selfhost:migrate must apply nothing. It prints the wall-clock time of every phase. Not part of task check or CI (it takes minutes). Knobs: REHEARSAL_HTTPS_PORT (9443), REHEARSAL_SKIP_BUILD=1, REHEARSAL_KEEP=1 (see the script header).

Development: task dev:tls

The dev stack normally runs on plain HTTP (task up, http://localhost:3041, http://<slug>--preview.apps.localhost:3041). To run it behind Caddy with its local CA:

task dev:tls        # generates .caddy/Caddyfile.dev (TLS_INTERNAL=1), starts caddy on :443,
                    # copies Caddy's root CA to .caddy/root.crt
curl --cacert .caddy/root.crt https://localhost/healthz
curl --cacert .caddy/root.crt \
  --resolve x--preview.apps.localhost:443:127.0.0.1 https://x--preview.apps.localhost/
task dev:tls:down   # remove caddy, back to the plain HTTP dev stack

It layers docker-compose.tls.yaml over the dev compose file: drobek switches to PUBLIC_APP_URL=https://localhost, APPS_DOMAIN=apps.localhost, APPS_URL_SCHEME=https and TRUST_PROXY=x-real-ip. If port 443 is taken on your machine, use task dev:tls DEV_TLS_PORT=8443 — every URL then carries :8443.

The root CA stays in the caddy_dev_data volume; drobek never installs it anywhere (skip_install_trust). To make browsers trust it, import .caddy/root.crt into your OS or browser trust store yourself — or keep using curl --cacert / NODE_EXTRA_CA_CERTS=.caddy/root.crt.