Deploy
This page is for the operator putting canvas-drop in production. By the end you run one Node process behind a TLS-terminating reverse proxy that asserts who the user is, with Postgres and S3-compatible storage beside it, and you know how to check its health, back it up, and upgrade it. The full variable reference is on Configuration.
The production recipe:
docker build -t canvas-drop .
cp .env.production.example canvas-drop.env # replace every CHANGE_ME / REPLACE_ME
docker run -d --name canvas-drop --env-file canvas-drop.env \
-p 127.0.0.1:3000:3000 -v canvas-drop-data:/data canvas-drop
curl -fsS http://127.0.0.1:3000/healthz
# {"status":"ok","db":"ok","version":"..."}
Then point your identity-aware proxy at 127.0.0.1:3000 under the contract below. To see
the whole shape running first, with a bundled IdP and no external setup, use the compose
stack:
docker compose up --build # add -d to run in the background
# open http://localhost:8080 and sign in as demo@example.com / canvasdrop
The app is a single Hono server: dashboard, auth, management and platform APIs, canvas content, MCP, docs, and the realtime WebSocket all share one port and one log stream. Identity only ever comes from the app's server-side auth context, so the proxy has one security job: assert the user correctly. Everything else on this page is a config switch.
Recommended production profile
| Interface | Setting | Notes |
|---|---|---|
| URL mode | CANVAS_DROP_URL_MODE=subdomain |
Every canvas is its own browser origin ({slug}.{base}). Needs a non-localhost CANVAS_DROP_BASE_URL, wildcard DNS (*.canvases.example.com), and a wildcard certificate at the proxy. |
| Auth | CANVAS_DROP_AUTH_MODE=proxy on the JWKS path |
An identity-aware proxy (IAP) in front forwards a signed JWT; the app verifies it. |
| Database | CANVAS_DROP_DB=postgres |
CANVAS_DROP_DATABASE_URL is required. |
| Storage | CANVAS_DROP_STORAGE=s3 |
Bucket, region, access key, and secret key are required; CANVAS_DROP_S3_ENDPOINT for MinIO or R2. |
| TLS | at the proxy | One certificate covering {base} and *.{base}. The proxy-to-app hop is plain HTTP. |
.env.production.example in the repo is this profile, annotated. Any Docker host, VPS,
PaaS, or Kubernetes cluster works; the design target is a single process on one modest
box. None of these choices is mandatory: SQLite plus local disk needs only a volume, and
each interface (database, storage, URL mode, auth) is a config switch you can change
later without touching code (see "Changing drivers later").
Shape
┌─────────────────────────┐
client ──▶│ identity-aware proxy/IAP │ terminates TLS, asserts identity
└────────────┬────────────┘
▼
canvas-drop app ── Postgres
└ S3-compatible storage
The app must not be reachable directly; only the proxy talks to it. The proxy must overwrite (never append to) the identity headers it forwards.
What the reverse proxy must do
Whichever proxy you run (an IAP, Caddy, nginx, a tunnel), the app needs these behaviors from it:
- Preserve
Host. In subdomain mode the app picks the canvas from theHostheader ({slug}.{baseHost}); the base host itself routes by path. Caddy forwardsHostby default; nginx needsproxy_set_header Host $host. - Pass WebSocket upgrades. The realtime primitive is a WebSocket on the same port as everything else.
- Allow large request bodies on deploy routes. The app accepts up to 110 MB on
PUT /v1/canvases/{id}/deployand the dashboard deploy routes, and 26 MB per staged blob (PUT /v1/canvases/{id}/uploads/{uploadId}/blobs/{hash}). It answers413above those limits itself; a proxy with a lower cap fails deploys first. - Forward
Authorizationuntouched on/v1/canvases/*and/mcp, and route those paths around the IAP's login (see "Agents and CI behind an IAP" below). - Set
CANVAS_DROP_BASE_URLto the publichttps://origin. The app builds every absolute URL (canvas URLs, the OIDC redirect URI, MCP discovery metadata) from that variable and never readsX-Forwarded-Proto, so the plain-HTTP hop between proxy and app is fine. - Give it 5 seconds to stop. On
SIGTERMthe app closes WebSockets, drops idle keep-alive connections, force-closes after 5 s, flushes the audit log, and closes the database. Set your stop grace period to at least that.
Auth at the edge
In proxy mode the app is sessionless: the IAP owns the session, and the app re-derives
identity on every request from what the proxy forwards, so a proxy-side logout takes
effect on the next request. Pick exactly one trust path. They do not compose.
- JWT / JWKS (preferred, cryptographic). The proxy forwards a signed JWT (default
header
Cf-Access-Jwt-Assertion; setCANVAS_DROP_AUTH_PROXY_JWT_HEADERto change it). SetCANVAS_DROP_AUTH_PROXY_JWT_JWKS_URL,CANVAS_DROP_AUTH_PROXY_JWT_ISSUER, andCANVAS_DROP_AUTH_PROXY_JWT_AUDIENCE; boot refuses a JWKS URL without both issuer and audience. The app checks the signature against the JWKS plusiss,aud, and expiry, and takes the identity from the token'semailclaim (a token without one resolves to anonymous). When a JWKS URL is set, plain identity headers are never honored: a strayX-Auth-Request-Emailwithout a valid JWT resolves to anonymous and is logged. - Trusted header (only when no JWKS URL is set). The app trusts the forwarded email
header (default
X-Auth-Request-Email; display name fromX-Auth-Request-Preferred-Username) only when the request's TCP peer IP is listed inCANVAS_DROP_TRUSTED_PROXY_IPS(CSV of IPv4 addresses or CIDRs). The peer IP is the socket address, never a forwarded header. Each entry is validated at boot:/0, malformed entries, and IPv6 are rejected, so "trust everything" cannot be configured. A header from any other peer is ignored and logged.
Proxy mode refuses to boot without a JWKS URL or a trusted-proxy IP list. In proxy and
oidc modes you must also set CANVAS_DROP_ALLOWED_EMAIL_DOMAINS (one or more domains;
CSV, lowercased), which is enforced on every request, and a CANVAS_DROP_SESSION_SECRET
of at least 32 characters (also required whenever NODE_ENV=production, which the Docker
image sets). A request that yields no identity gets 401 { "error": "unauthorized" }
in proxy mode; the IAP is expected to have bounced it first. The
Security model covers the invariants behind these
rules.
Running real auth (proxy or oidc) in path URL mode also requires
CANVAS_DROP_ALLOW_MULTI_USER_PATH_MODE=true, because path mode puts every canvas on one
browser origin. Set it only when you accept that tradeoff. The demo compose stack does,
since subdomain cannot boot on localhost. subdomain mode needs no opt-in.
The third mode, dev, auto-signs-in a fixed local user with no verification and is
rejected at boot when NODE_ENV=production.
Agents and CI behind an IAP
Two surfaces authenticate themselves and never carry the IAP's browser session:
- The Deploy API,
/v1/canvases/*, authenticates with a per-canvas secret key (Authorization: Bearer cd_...). - The MCP endpoint,
/mcp, authenticates with an OAuth 2.1 bearer token issued by the app itself. Clients discover the endpoints at/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource, register at/register, and exchange codes at/token(/revokerevokes). Only the/authorizestep is a browser round-trip, and it does go through the IAP.
Configure the IAP to let /v1/canvases/*, /mcp, /.well-known/*, /register,
/token, and /revoke through unauthenticated and to forward Authorization unchanged;
the app rejects bad or missing tokens with 401. In subdomain mode a common layout is a
dedicated API host: set CANVAS_DROP_API_BASE_URL (for example
https://api.canvases.example.com) so the MCP tools advertise the right endpoints to
agents; api, v1, sdk, auth, and mcp are reserved slugs, so no canvas can
collide with it. The demo Caddyfile strips Authorization on purpose, which is why the
Deploy API is unreachable in the demo.
Two related facts about anonymous traffic. GET /healthz is unauthenticated at the app;
the container health check calls it from inside, so expose it externally only if a
monitor needs it. And a Public link canvas is only public if the IAP lets anonymous
requests reach the app: the app serves that rung (static files only) to a request with
no identity, and nothing else. Behind an IAP that demands sign-in on every host, public
links land on the IAP's login page.
Without an identity-aware proxy: oidc
If you do not run an IAP, use the built-in oidc mode and point it at your OpenID
provider. Run it in subdomain mode so you keep per-canvas origin isolation without
standing up an IAP; a plain TLS-terminating proxy in front is enough.
CANVAS_DROP_URL_MODE=subdomain
CANVAS_DROP_BASE_URL=https://canvases.example.com
CANVAS_DROP_AUTH_MODE=oidc
CANVAS_DROP_OIDC_ISSUER=https://accounts.example.com
CANVAS_DROP_OIDC_CLIENT_ID=...
CANVAS_DROP_OIDC_CLIENT_SECRET=...
CANVAS_DROP_ALLOWED_EMAIL_DOMAINS=example.com
CANVAS_DROP_SESSION_SECRET=... # >= 32 chars; openssl rand -hex 32
Register {base}/auth/callback as the redirect URI at your provider. The app runs
Authorization Code + PKCE (S256) with scope=openid email profile, requires an email
claim, rejects a token whose email_verified is false, and issues its own session
cookie (__canvasdrop_session: HttpOnly, Secure in production, SameSite=Lax,
scoped to .{baseHost} in subdomain mode and host-only in path mode, 14-day rolling
expiry). A request with no session is redirected to /auth/login?returnTo=...;
/auth/logout revokes the local session only. Every rejected sign-in is audited as
auth_denied with its reason.
oidc mode also honors CANVAS_DROP_TRUSTED_PROXY_IPS (and CANVAS_DROP_CLIENT_IP_HEADER)
so login throttling and audit rows key on the real client IP behind your proxy; in this
mode those settings never assert identity. See Behind a CDN.
Run with Docker
There is no published image; build it from the repo. The Dockerfile is multi-stage on
node:24-slim: a builder stage compiles the workspace (it needs python3, make, and
g++ for the SQLite driver), and a runtime stage carries no compilers and runs as a
dedicated non-root canvasdrop user (uid/gid 1001).
docker build -t canvas-drop .
The image's operational contract:
- Entry:
node --conditions=node-dist apps/server/dist/index.js, withNODE_ENV=production,CANVAS_DROP_PORT=3000(EXPOSE 3000), andCANVAS_DROP_DASHBOARD_DIST=/app/apps/dashboard/distpreset. BecauseNODE_ENVisproduction,devauth is refused; configureproxyoroidc. - State:
VOLUME /data, pre-created and owned by the non-root user. In-image defaults:CANVAS_DROP_SQLITE_PATH=/data/canvasdrop.dbandCANVAS_DROP_STORAGE_PATH=/data/storage. Mount a volume there on the SQLite + local-storage profile; on Postgres + S3 the container holds no state. - Health:
HEALTHCHECKfetcheshttp://127.0.0.1:3000/healthzevery 15 s (5 s timeout, 5 retries) with a 60 s start period, which covers Postgres coming up and migrations running on a cold start. Wire the same URL into your orchestrator's readiness probe. - Config: pass
CANVAS_DROP_*as container environment (--env-fileor your orchestrator's secrets). The image reads no.env. - Screenshots (optional): the default image has no browser. Build with
--build-arg SCREENSHOTS=1to add Chromium (about 300 MB), then run withCANVAS_DROP_SCREENSHOTS=onand flip the admin toggle. See Screenshots.
The compose stack
docker-compose.yml boots the whole production shape with no external setup. It is a
demo of the wiring, not a production deployment as-is.
| Service | Image | Role |
|---|---|---|
caddy |
caddy:2-alpine |
Edge proxy and the only service with a published port (8080). Routes /dex/* to Dex and everything else to oauth2-proxy, and strips client-supplied X-Forwarded-Access-Token, X-Auth-Request-Email, X-Auth-Request-User, X-Auth-Request-Preferred-Username, and Authorization headers on the way in. Plain HTTP for the demo; in production it would terminate TLS. |
oauth2-proxy |
quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 |
The identity-aware proxy. Signs users in against Dex and forwards the Dex-signed access token to the app in X-Forwarded-Access-Token. |
dex |
dexidp/dex:v2.41.1 |
Bundled demo IdP with one static user, demo@example.com / canvasdrop. |
app |
built from the repo as canvas-drop:dev |
canvas-drop in real proxy mode on the JWKS path: CANVAS_DROP_AUTH_PROXY_JWT_HEADER=X-Forwarded-Access-Token, CANVAS_DROP_AUTH_PROXY_JWT_JWKS_URL=http://dex:5556/dex/keys, issuer http://localhost:8080/dex, audience canvas-drop-demo. path URL mode with CANVAS_DROP_ALLOW_MULTI_USER_PATH_MODE=true, Postgres, local storage on the app-data volume, plus a backups volume. No published port; waits for the Postgres health check. |
postgres |
postgres:16-alpine |
The database, on the pg-data volume, with a pg_isready health check. |
seaweedfs |
chrislusf/seaweedfs:4.46 |
Optional S3-compatible object storage behind --profile s3. Starting it does not switch the app to S3; edit the app service's environment: block as shown on Install, and create the bucket yourself. |
The app verifies a Dex-signed JWT against Dex's JWKS, the same cryptographic trust path
you run in production. The smoke test boots the stack and asserts the launch invariants:
the app reports healthy, the app has no host port, an unauthenticated request is
redirected, a forged X-Forwarded-Access-Token / X-Auth-Request-Email pair is still
redirected, a real Dex login resolves demo@example.com with authMode: proxy, and the
same user id survives docker compose restart app postgres.
./scripts/compose-smoke.sh # leaves the stack running
KEEP_UP=0 ./scripts/compose-smoke.sh # tears it down (docker compose down -v)
Every Dex and oauth2-proxy secret under docker/ is a labeled demo placeholder, and the
stack runs plain HTTP in path mode. Work the checklist below before anyone but you can
reach it.
The compose file also carries a commented-out maintenance service: a supercronic
sidecar on the same image and volumes that runs docker/maintenance.cron, a nightly
backup at 03:15 UTC (tarred, 14-day retention, the prune runs only after a good new
backup exists) and purge 30 at 03:45 UTC on Sundays. Uncomment it, keep its
CANVAS_DROP_DB / storage variables in step with the app service, and start it with
docker compose --profile maintenance up -d.
Graduating to a real IdP
Moving off the bundled IdP is configuration, not code, but each step matters:
- Point oauth2-proxy (or your own IAP) at your real provider, and set
CANVAS_DROP_AUTH_PROXY_JWT_JWKS_URL,CANVAS_DROP_AUTH_PROXY_JWT_ISSUER, andCANVAS_DROP_AUTH_PROXY_JWT_AUDIENCEto match the tokens it forwards. - Confirm the forwarded JWT carries the verified email in its
emailclaim; a token without one resolves to anonymous. - Set
CANVAS_DROP_ALLOWED_EMAIL_DOMAINSto your real domains andCANVAS_DROP_ADMIN_EMAILSto your bootstrap admins. - Switch to
CANVAS_DROP_URL_MODE=subdomainonce wildcard DNS and a wildcard certificate exist, and dropCANVAS_DROP_ALLOW_MULTI_USER_PATH_MODE. - Rotate every demo secret, including
CANVAS_DROP_SESSION_SECRET; setcookie_secure=trueat oauth2-proxy once TLS terminates at the edge. - If agents or CI will deploy, stop stripping
Authorizationfor/v1/canvases/*and/mcpand exempt those paths from the IAP's login. - Re-run the forged-token check against the new wiring.
Running the bare process
To run without Docker, build the workspace and start the compiled server. Node 24 or
newer and pnpm 11 (corepack enable picks up the pinned version) are required.
pnpm install --frozen-lockfile
pnpm build
node --conditions=node-dist apps/server/dist/index.js
pnpm build compiles the shared package to dist/ first; the node-dist export
condition makes @canvas-drop/shared resolve to that compiled JS, so production runs
without tsx.
Supply configuration through the process manager, not a .env file; only pnpm dev
reads .env. A systemd unit with EnvironmentFile= and a TLS proxy in front (Caddy,
nginx, a tunnel) is enough for a small instance on one box: the app binds a local port
(default 3000) and the proxy reverse-proxies to it under the contract above. If the
port is already bound, the process prints a hint and exits 1 rather than retrying.
Health, boot, and upgrades
GET /healthz pings the database and answers {"status":"ok","db":"ok","version":...}
with 200, or {"status":"degraded","db":"error",...} with 503 when the database is
unreachable. At boot the process validates its configuration first and exits 1 with a
message naming every invalid variable, then connects to the database, runs pending
migrations, opens storage, and only then opens the port. A passing health check
therefore means the database is reachable and the schema is current.
Migrations run at boot in every mode; there is no separate migrate command. To upgrade, take a backup, replace the image or the built tree, and restart. Migrations are written to be additive, so an existing database is not rewritten in place; the backup is your rollback.
Rate-limit counters (fixed 60 s windows, CANVAS_DROP_RATELIMIT_*) and realtime channels
live in process memory, which is why the profile above is one process per instance.
Backups and maintenance
The server binary doubles as the ops tool. The same image, the same config, no extra tooling:
BIN="node --conditions=node-dist apps/server/dist/index.js"
$BIN backup /backups/$(date -u +%Y%m%dT%H%M%SZ) # whole instance: every table + every blob
$BIN restore /backups/20260620T031500Z # into an empty instance; --force to overwrite
$BIN purge 30 # reclaim canvases deleted 30+ days ago; add dry-run to preview
A backup is a self-describing directory (meta.json, db/<table>.ndjson, blobs/<key>)
written through the database and storage interfaces, so it is driver-agnostic: a backup
taken on SQLite + local disk restores into Postgres + S3 and vice versa. That makes
backup then restore the supported way to migrate between drivers. backup runs pending
migrations before it reads. restore refuses a non-empty database without --force and
verifies row counts, blob count and bytes, and every blob's SHA-256 before writing
anything.
A backup is as sensitive as the database: it is a cleartext export of credential material (password and API-key hashes, OAuth client secrets, session and MCP-token rows) and personal data (allowed emails, audit-log actor IPs). Keep it on a separate volume, restrict it to the app user, and encrypt it before it leaves the host. Run a restore drill into a throwaway instance periodically; a green restore is the only proof a backup is real.
purge [days] reclaims the storage and version rows of canvases soft-deleted longer ago
than days, then prunes usage_events and ai_usage rows older than 90 days. A
sensible schedule is a nightly backup with 14-day local retention and a weekly
purge 30, either from a host crontab or the compose maintenance sidecar. The full
runbook, including the crontab lines and the restore drill, is docs/ops.md in the repo.
Logs
Structured JSON to stdout via pino: no app-side files, rotation, or shipping. Tune with
LOG_LEVEL (default info) and LOG_FORMAT (json when NODE_ENV=production,
otherwise pretty). Each request gets a correlation ID, taken from an inbound
X-Correlation-ID or X-Request-Id header or generated, and echoed back as
X-Correlation-ID. /healthz is excluded from request logging. Error tracking is off
unless you set CANVAS_DROP_SENTRY_DSN. There is no telemetry or phone-home.
Changing drivers later
Start small and change env, never code:
| Move | Set |
|---|---|
| Local disk to object storage | CANVAS_DROP_STORAGE=s3 plus CANVAS_DROP_S3_BUCKET, CANVAS_DROP_S3_REGION, CANVAS_DROP_S3_ACCESS_KEY, CANVAS_DROP_S3_SECRET_KEY; CANVAS_DROP_S3_ENDPOINT for MinIO or R2 (CANVAS_DROP_S3_FORCE_PATH_STYLE defaults to true). |
| SQLite to Postgres | CANVAS_DROP_DB=postgres plus CANVAS_DROP_DATABASE_URL. |
| Built-in login to an IAP | Put a JWT-issuing identity-aware proxy in front and set CANVAS_DROP_AUTH_MODE=proxy with the JWKS variables above. |
| A CDN in front | See Behind a CDN: trusted-proxy IPs, the client-IP header, a cache rule that bypasses on the session cookie, and CANVAS_DROP_PUBLIC_EDGE_CACHE_TTL. |
Switching a driver changes where new data goes; to carry existing data across, take a backup on the old drivers and restore it on the new ones.
See Configuration for the full env surface.
Runtime permission upgrades
Before deploying the participant permission model, follow the upgrade guide. Its additive migrations preserve data but default AI/Connections audiences to owners/editors and change shared KV/files/realtime mutation rules. Inventory existing interactive canvases as part of deployment preparation.