Skip to content
Browse documentation

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.

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 the Host header ({slug}.{baseHost}); the base host itself routes by path. Caddy forwards Host by default; nginx needs proxy_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}/deploy and the dashboard deploy routes, and 26 MB per staged blob (PUT /v1/canvases/{id}/uploads/{uploadId}/blobs/{hash}). It answers 413 above those limits itself; a proxy with a lower cap fails deploys first.
  • Forward Authorization untouched 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_URL to the public https:// origin. The app builds every absolute URL (canvas URLs, the OIDC redirect URI, MCP discovery metadata) from that variable and never reads X-Forwarded-Proto, so the plain-HTTP hop between proxy and app is fine.
  • Give it 5 seconds to stop. On SIGTERM the 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; set CANVAS_DROP_AUTH_PROXY_JWT_HEADER to change it). Set CANVAS_DROP_AUTH_PROXY_JWT_JWKS_URL, CANVAS_DROP_AUTH_PROXY_JWT_ISSUER, and CANVAS_DROP_AUTH_PROXY_JWT_AUDIENCE; boot refuses a JWKS URL without both issuer and audience. The app checks the signature against the JWKS plus iss, aud, and expiry, and takes the identity from the token's email claim (a token without one resolves to anonymous). When a JWKS URL is set, plain identity headers are never honored: a stray X-Auth-Request-Email without 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 from X-Auth-Request-Preferred-Username) only when the request's TCP peer IP is listed in CANVAS_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-server and /.well-known/oauth-protected-resource, register at /register, and exchange codes at /token (/revoke revokes). Only the /authorize step 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, with NODE_ENV=production, CANVAS_DROP_PORT=3000 (EXPOSE 3000), and CANVAS_DROP_DASHBOARD_DIST=/app/apps/dashboard/dist preset. Because NODE_ENV is production, dev auth is refused; configure proxy or oidc.
  • State: VOLUME /data, pre-created and owned by the non-root user. In-image defaults: CANVAS_DROP_SQLITE_PATH=/data/canvasdrop.db and CANVAS_DROP_STORAGE_PATH=/data/storage. Mount a volume there on the SQLite + local-storage profile; on Postgres + S3 the container holds no state.
  • Health: HEALTHCHECK fetches http://127.0.0.1:3000/healthz every 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-file or your orchestrator's secrets). The image reads no .env.
  • Screenshots (optional): the default image has no browser. Build with --build-arg SCREENSHOTS=1 to add Chromium (about 300 MB), then run with CANVAS_DROP_SCREENSHOTS=on and 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:

  1. 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, and CANVAS_DROP_AUTH_PROXY_JWT_AUDIENCE to match the tokens it forwards.
  2. Confirm the forwarded JWT carries the verified email in its email claim; a token without one resolves to anonymous.
  3. Set CANVAS_DROP_ALLOWED_EMAIL_DOMAINS to your real domains and CANVAS_DROP_ADMIN_EMAILS to your bootstrap admins.
  4. Switch to CANVAS_DROP_URL_MODE=subdomain once wildcard DNS and a wildcard certificate exist, and drop CANVAS_DROP_ALLOW_MULTI_USER_PATH_MODE.
  5. Rotate every demo secret, including CANVAS_DROP_SESSION_SECRET; set cookie_secure=true at oauth2-proxy once TLS terminates at the edge.
  6. If agents or CI will deploy, stop stripping Authorization for /v1/canvases/* and /mcp and exempt those paths from the IAP's login.
  7. 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.