The whole stack runs in Docker Compose: Druks (web, which embeds the DBOS
durable engine and serves the dashboard SPA), Postgres, and Redis. A
remote install (any DRUKS_PROVIDER except docker) also brings up stock
Caddy (identity edge + proxy, Caddyfile bind-mounted) and the Drukbox sandbox
control plane (sandbox-service, sandbox-janitor). Those three live in the
Compose remote profile, which install.sh selects by writing
COMPOSE_PROFILES to .env, so plain docker compose commands in the install
dir pick it up.
A local install (DRUKS_PROVIDER=docker) runs neither: the dashboard is
reached directly on 127.0.0.1:8001 and sandboxes are local Docker
containers with drukbox on the host — see
Full local.
The Druks services use the host network so they can reach Postgres, Redis, Drukbox, and provider-specific sandbox addresses from the host network namespace. The default exe.dev shape reaches VMs over the host's tailnet; other providers may return directly reachable SSH addresses.
Prerequisites: Docker with the Compose plugin. The default exe.dev shape also
needs tailscaled joined to the intended tailnet (tailscale status shows
peers). Other remote providers have their own network and credential
requirements; the local Docker shape is covered in
Full local.
The Druks application and sandbox images are published for both linux/amd64
and linux/arm64.
Everything else — compose.yaml, the Caddyfile, druks.toml, the rendered
.env, image pulls, and DB init — is handled by install.sh. (While the
ghcr.io images require auth, export GHCR_TOKEN — a PAT with read:packages
— and the installer logs in with it.)
bash <(curl -fsSL https://raw.githubusercontent.com/czpython/druks/main/scripts/install.sh)First pass writes ~/druks/druks.toml with random secrets pre-filled, creates
~/druks/secrets/, renders ~/druks/.env, and exits when required values are
missing. It tells you exactly what to do next:
- Edit
druks.toml. For a generic remote shape, fill[sandbox.<provider>]from Drukbox's configuration reference. - Provision the two GitHub Apps: re-run the installer with
--apps. It registers each app via GitHub's manifest flow — it prints a link per app, you open it, click Create, then paste the?code=...from the redirect back into the SSH session. App ids, PEMs, and the webhook secret are applied throughdruks setup, the singledruks.tomlwriter. (Manual fallback: create the apps by hand per the permission tables indocs/configuration.md, enter their ids under[github], and upload the PEMs to~/druks/secrets/{operator,reviewer}.pem.)
The sandbox backend defaults to exe.dev + Tailscale. Pass DRUKS_PROVIDER
on the first run to choose another. docker selects the local shape; any other
Drukbox provider name selects the generic remote shape and is passed through
without a Druks provider registry. Re-runs read [sandbox].provider from
druks.toml, so the environment flag is only a fresh-install seed.
Override the install dir with DRUKS_INSTALL_DIR=/srv/druks if you
want it elsewhere.
bash <(curl -fsSL https://raw.githubusercontent.com/czpython/druks/main/scripts/install.sh)Second pass renders .env from druks.toml, validates the required values and
PEMs, then: docker compose pull → migrate the databases out of band
(docker compose run --rm web druks init-db, plus drukbox's schema on a
remote install) → docker compose up -d. Nothing migrates on boot.
cd ~/druks
docker compose ps
curl -fsS http://127.0.0.1:8001/healthThen connect Claude and Codex from the dashboard (Settings → Harnesses → Connect): each card opens the provider's authorize page and takes the pasted code back. Subscription tokens live in the database — no host CLI login — and agent runs refuse to start on a harness that isn't connected.
After both connections:
docker compose exec web druks doctorEvery configured check should pass. Before the connections, the same command is still useful for infrastructure but correctly reports both harness credential checks as failures.
On exe.dev, one port-share carries both dashboard and webhooks:
ssh exe.dev share port druks 8000
ssh exe.dev share set-public druksPublic URLs: https://<host>/_external/{github,linear,jira}/events/
(authenticated provider webhooks), https://<host>/mcp (PAT-authenticated MCP endpoint —
Connect your agent), and https://<host>/
(exe.dev authenticates at the edge; druks maps its asserted email to your
account).
On another remote provider, the dashboard goes through your identity proxy
(set [identity].header in druks.toml to the header it injects), but
webhook senders can't authenticate through SSO — they need their own
public HTTPS path. The stack's Caddy provides it:
The proxy must strip any client-supplied copy of that identity header before inserting its authenticated value. It must also terminate TLS and set HSTS; Druks' shipped Caddy dashboard listener is loopback HTTP behind that edge.
- Point an A-record (e.g.
druks.example.com) at the box and open inbound 80 + 443. - Set
[urls].webhook_host = "druks.example.com"indruks.tomland re-run the installer.
Caddy auto-provisions Let's Encrypt for that hostname and serves only
POST /_external/* and the PAT-authenticated /mcp endpoint on it — no
dashboard routes, no identity header, so the SSO gate can't be forged from
the public side. Webhook URLs become
https://druks.example.com/_external/<provider>/events/; agents connect at
https://druks.example.com/mcp
(Connect your agent). Leave
[urls].webhook_host blank to bring your own ingress instead.
Edit druks.toml, then re-run the installer from the same version you intend
to deploy. It renders .env, refreshes compose.yaml and the Caddyfile
from the repo, applies any new migrations out of band
(docker compose run --rm web druks init-db, plus drukbox's on a remote
install), then runs docker compose pull && up -d, so it is the upgrade
path. Compose recreates only changed services. To migrate by hand without the
installer, run that same docker compose run --rm web druks init-db.
Recreating web interrupts in-flight execution. DBOS recovers compatible
workflows from completed checkpoints when the process returns. Treat changes to
workflow structure, step order or names, and serialized input as deployment
compatibility changes: drain affected runs or keep an executor with compatible
code until they finish. Recovery does not preserve a live agent process inside
a sandbox; it follows the operation boundary described in
Concepts.
The backend containers now run as the deploy user (DRUKS_UID/DRUKS_GID),
not root. A box deployed before this change has root-owned files the non-root
containers must write — the logs/ + prompt-cache/ dirs under the data dir.
install.sh sets DRUKS_UID/DRUKS_GID and chowns the sandbox-keys volume on
re-run, but it runs unprivileged, so take over the root-owned host files once
first (adjust the path if you customized DRUKS_DATA_HOST_DIR):
docker run --rm -v "$HOME/druks-data:/d" alpine chown -R "$(id -u):$(id -g)" /dA root container chowns the bind-mounted host paths, so no host sudo. Then
run the normal upgrade (re-run install.sh) — it writes DRUKS_UID/DRUKS_GID,
chowns the sandbox-keys volume, and recreates the stack as the deploy user. New
installs need none of this: install.sh and the deploy user own everything
from the start.
main and latest are the edge channel. For a tagged install, fetch the
installer from that tag and set the same DRUKS_REF; it automatically selects
the matching image tag. See Releasing Druks for the
immutable install shape.
The Druks image is tagged :sha-<full-git-sha> per commit and carries both
the API and the SPA build — one artifact, nothing to keep in lockstep.
Pin a specific build by setting DRUKS_TAG in .env:
DRUKS_TAG=sha-0123456789abcdef0123456789abcdef01234567 docker compose up -dThis rolls back the image, not the database schema. druks init-db only
upgrades; it does not downgrade migrations. Before pinning an older image,
confirm that its code can read the current schema, the current druks.toml
and rendered .env, and that in-flight workflows are compatible with that
code.
docker compose logs -f web
docker compose logs -f sandbox-service sandbox-janitor # remote install only
docker compose downexe.dev exposes one port; Caddy (stock image, host network, :8000,
Caddyfile fetched by the installer) enforces path-level access:
POST /_external/*— public, authenticated by the matching webhook class in Druks. Per-provider paths land under/_external/<provider>/<category>/; extension role-module discovery registers them at import time./mcp— public, authenticated per request by personal access token inside the app; proxied unbuffered so its SSE frames stream.- Everything else — a nonempty trusted identity header (exe.dev login
provides one) required, then proxied to
web(127.0.0.1:8001), which serves the API, the SPA, and extension frontends alike; the app maps that asserted email to your account per request (access control).