Dock dock.build · v0.3
Execution run-book

Self-host in 90 seconds, or it doesn't ship.

This page is the build plan, held to one bar: ease of self-host beats perfect infra, every time the two conflict. One container, embedded database, no DNS homework, auth that works without an IdP — and the exact same image running our hosted tier.

Self-host experience

Three modes, one image, graduate when you need to

The Gitea playbook, not the Kubernetes playbook. The default install has zero external dependencies: SQLite for metadata, local disk for blobs, both inside one volume. Postgres and S3 are config changes, not migrations of your mental model. This supersedes the compose-first story on the Technical page — compose is now Mode 1.

MODE 0 · DEFAULT One container
Individuals, small teams, evaluation → honestly, most teams forever
  • SQLite (WAL) + local-disk blobs in /data
  • Handles thousands of artifacts, dozens of users
  • Backup = snapshot one volume
  • 90 seconds, no YAML
MODE 1 · COMPOSE + Postgres & MinIO
Companies; multi-replica; ops teams that want real DB tooling
  • DATABASE_URL + OBJECT_STORE_URL — that's the diff
  • dock migrate --from-sqlite one-shot mover
  • SSE fan-out via Postgres LISTEN/NOTIFY
MODE 2 · HELM Enterprise k8s
Their VPC, their managed Postgres/S3, their ingress
  • Stateless replicas; chart is thin (it's still just the one image)
  • SAML/SCIM config, audit retention knobs
  • What we sell support contracts against

The no-DNS problem, solved by default

Per-artifact subdomains (full origin isolation) require wildcard DNS + a wildcard cert — the #1 thing that makes self-hosting "pages" products miserable. So sandboxing is tiered:

  • Embedded mode (default, zero config): artifacts render in an iframe sandbox="allow-scripts" without allow-same-origin — the browser gives the content an opaque origin, so scripts run but can touch no cookies, storage, or APIs. Safe on a bare IP address.
  • Domain mode (one env var): set ARTIFACT_HOST=*.artifacts.acme.com and artifacts get full-window, own-origin serving. Caddy bundled in the image does the wildcard TLS automatically via ACME DNS challenge.

Config surface: small enough to memorize

# everything is optional; defaults shown BASE_URL=http://localhost:8080 DATA_DIR=/data # sqlite + blobs live here DATABASE_URL= # set → Postgres mode OBJECT_STORE_URL= # set → S3/GCS/R2 mode ARTIFACT_HOST= # set → full origin isolation OIDC_ISSUER / CLIENT_ID / SECRET= # set → SSO SMTP_URL= # set → email invites/magic links

Rule: every feature degrades gracefully when its var is unset. Nothing crashes on missing config.

Auth

Auth that works before anyone talks to IT

The killer failure mode for self-hosted tools is "now configure your IdP" on day one. Dock's ladder: local accounts → SMTP magic links → OIDC → SAML/SCIM, each unlocked by config, none required to start. Machine auth (CLI device flow, agent tokens) is identical in every mode and specced on the Spec page.

RungHow humans sign inNeeds
LocalAdmin creates users in the wizard / invites via link. Email + password, argon2id, optional TOTP.nothing
Magic linkEmail link sign-in; invite flows get nicer.SMTP_URL
OIDC"Continue with Okta/Entra/Google" — JIT-provisioned, org-gated by email domain or IdP group.3 env vars
SAML + SCIMLegacy IdPs; auto-deprovisioning. Enterprise tier.config + license

Hosted tier is the same ladder with rungs pre-pulled: magic link + Google/Microsoft OAuth on by default, org = verified email domain.

HOW A GATED ARTIFACT CHECKS YOU — SAME IN EVERY MODE Vieweropens artifact URL Dock appsession cookie valid? Sign-inlocal · SSO · link no → ACL checkpublic·link·org·password yes ↓ Serve contentsandboxed iframe or origin domain mode adds: short-lived signed token minted for the artifact origin, so subdomains never see the real session

Parity

Self-host vs hosted: one codebase, honest differences

The trust contract with the OSS community: the hosted product runs the public image, and feature gates never live in the core. Differences exist only where physics or business demand them — and we publish the list.

CapabilitySelf-hostHosted
Publish, render, versions, comments, MCP, SSE, search✓ full engine✓ same image
Sign-in ladderlocal → SMTP → OIDC → SAMLmagic link + Google/Microsoft preconfigured
TLS + wildcard artifact domainbundled Caddy/ACME, or your ingressdone for you (*.dock.build)
Emailyour SMTP_URLbuilt-in
Version retentionunlimited (your disk)tier-based (comment-referenced versions always kept)
Billing moduledormant code pathactive
Telemetryoff by default; opt-in, anonymous, source-visiblestandard product analytics
Upgradesdocker pull; migrations run on bootcontinuous
Supportcommunity · paid contract (Enterprise)included

Release & upgrade contract

Single image ghcr.io/niftory/dock, semver tags, hosted always runs the latest public tag (we are the canary, not the special case). Migrations are forward-only and run on boot with a schema-version gate; N-1 image rollback is always safe because migrations within a minor version are additive-only. Backup story: Mode 0 = snapshot the volume; Mode 1+ = pg_dump + bucket versioning. Documented, tested in CI.

Codebase

Repo layout: a monorepo a stranger can navigate

dock.build/ # github.com/Niftory/dock.build — as built ├── apps/api/ # Hono on Node — THE container (API + viewer + raw) │ └── src/{app,node}.ts # createApp(deps) · env entrypoint ├── apps/web/ # TanStack Start (Vite) → static on CF; embedded for self-host ├── packages/core/ # runtime-agnostic domain: ports, publish, md, shell ├── packages/db/ # MetaStore: sqlite (default) · pg/neon (env) · d1 ├── packages/storage/ # BlobStore: fs (default) · s3/r2 (env) ├── packages/cli/ # dock publish <file|dir> (+login/comments later) ├── packages/{mcp,protocol,conformance} # M2+ ├── deploy/ # Dockerfile · compose.yml ├── docs/plan/ # this plan (dogfood: lives in the repo) └── spec/APP-0.md # the standard, versioned by RFC
  • Core owns the ports; packages adapt. MetaStore + BlobStore interfaces live in packages/core; sqlite/pg and fs/r2 drivers implement them and pass one shared test suite. The seam Unkey wished it had.
  • UI compiles into the container's assets (Outline's vite.config.ts + server/ shape; Ghost's admin app). One process self-hosts everything; hosted serves the same build from Cloudflare.
  • The protocol package is dependency-free and published to npm — implementers import types without touching our server.
  • CLI is a workspace package (n8n's packages/cli); CLI and MCP share one protocol client.
  • Conformance suite runs on every PR — the spec can't drift from the implementation, including ours.

The stack, locked

Every layer modeled on a world-class OSS repo

Nothing speculative: each choice names the proven project we copied it from, with the receipt in their repo. The one anti-pattern we dodged is also named.

LayerChoiceModeled on (receipt)
Monorepopnpm workspaces, apps/ + packages/ (Turbo when build graph grows)n8n (pnpm-workspace.yaml, turbo.json) · Ghost (pnpm-workspace.yaml, nx.json) · Cal.com (turbo.json)
API runtimeOne Node 22 container, Hono HTTP (web-standard, portable; zero framework lock-in)Unkey post-mortem: built on CF Workers, rewrote to a stateful monolith — "self-hosting was nearly impossible" (InfoQ, Dec 2025)
Web appTanStack Start (Vite) → static build; deployed to Cloudflare hosted, embedded in the container self-hostOutline (root vite.config.ts, app/+server/ one container) · Ghost (admin app served by core)
DatabaseSQLite default (better-sqlite3) → Neon Postgres via DATABASE_URL; same five-table scheman8n (DB_TYPE defaults sqlite → postgresdb) · Ghost (sqlite dev / MySQL prod)
BlobsLocal disk default → S3-compatible / R2 via env (zero-egress = the hosting economics)Outline (FILE_STORAGE=local|s3)
Render pathmarked (GFM) + xss allowlist sanitizer; fflate for bundles; WebCrypto sha-256; nanoid idsPure-JS, runtime-agnostic picks — every dep runs on Node and edge alike
Tests / CIVitest; drivers verified against one shared suite; GitHub ActionsStandard across n8n / Cal.com / Outline
Self-hostdocker run -v dock:/data ghcr.io/niftory/dock — wizard, env ladder, nothing requiredn8n (docker run n8nio/n8n, SQLite default) · Gitea/PocketBase as the ergonomics bar
Hosted deployCF static (web) + a container or two (api, Fly/Hetzner/CF Containers) + Neon + R2; CF proxy caches immutable @vN. Not Vercel — egress economics.Ghost(Pro)/n8n cloud (containers + managed DB) · Plausible (Hetzner) · Quick (one VM + object store)
LicenseApache-2.0, no /ee splitGhost (MIT) · Cal.com (now 100% MIT, no open-core split)

Product mechanics (comments, share links, per-doc permissions) study Outline; self-host ergonomics study n8n; the business study is Ghost. Unkey is the tuition we didn't have to pay.

Build sequence

Twelve weeks to OSS launch, 2–3 engineers

Maps the M1–M6 milestones to calendar weeks with hard exit criteria. Every phase ends with something dogfooded, not something demoed.

WK 1–2
Skeleton + publish path (M1)Repo, storage interfaces, SQLite + fs drivers, POST /artifacts for files and static bundles (zip/folder, SPA fallback), sandboxed render (embedded mode), short_id URLs, CLI publish.
exit: an Astro dist/ publishes from CLI & renders, restart-safe
WK 3–4
Versions + read-back (M2)Version chain, @vN, restore, MD read-back with HTML→MD conversion, ?diff for MD. MCP server with publish/get.
exit: agent publishes v2 against v1 via MCP
WK 5–7
Comments + the loop (M3)Web Annotation anchors, threads UI, re-anchoring algorithm, SSE stream + presence, list_comments/reply_comment MCP, resolves[] on republish.
exit: full loop live; we run our own plan reviews on it
WK 8–9
Auth + ACLs (M4)Local accounts + wizard, sessions, device flow + tokens, OIDC, per-artifact visibility, password/link gates, audit log, domain mode + bundled Caddy.
exit: gated artifact safe on the public internet
WK 10–11
Self-host polish (M5)Postgres + S3 drivers green on shared suite, migrate --from-sqlite, compose + Helm, docs site (published as Dock artifacts, obviously), conformance suite, backup/upgrade docs.
exit: a design partner self-hosts from docs alone, no Slack help
WK 12
LaunchRepo public, APP-0 spec + RFC process, Claude Code/Codex skills submitted, hosted free tier open, Show HN / launch post with the live loop demo.
exit: first external PR + first stranger-org in production

Hosted tier hardening (billing, quotas, abuse pipeline, status page) runs weeks 10–14 in parallel — it's ops work on the same image, not product work.

Day-2 ops

What keeps it boring in production

health

/healthz + /readyz

Liveness, readiness (DB + blob probe), and /metrics in Prometheus format. Self-hosters' existing dashboards just work.

abuse

Quotas & takedown

Per-org storage/rate quotas enforced in core (self-hosters want them too). Hosted adds an abuse-report endpoint and a one-flag takedown that 410s the artifact.

telemetry

Opt-in, inspectable

Self-host telemetry is off until the wizard asks once. Payload is a counted feature list, no content, no URLs — and the exact code that sends it is 50 lines you can read.

Go / no-go

Launch checklist

90-second install verified on a clean VM by someone who didn't build it
Loop demo: publish → comment → agent republish, recorded end-to-end
Security review of sandbox modes; XSS bounty doc written
APP-0 spec + conformance suite pass against our own server
Claude Code skill + MCP server tested from a fresh machine
Upgrade path tested: v0.1 → v0.2 with live data, then rollback
Backup/restore doc executed verbatim by a second person
Docs site self-hosted on Dock itself (dogfood proof)
License headers, CLA/DCO decision, SECURITY.md, code of conduct
Hosted free tier: signup → publish in under 2 minutes