Architecture
How Spawner is built, for those who read or change its code: the pieces, the API, the engine, the dashboard, the CLI and the releases.
Concepts describes what it does from the user’s side, and security what it protects and why; this page says where each part lives.
The pieces
Section titled “The pieces” ports 80 and 443 | spawner-traefik file provider, no Docker socket | | spawner-core network one network per environment | | spawner web, api, db of each environment, API, dashboard, a Compose project: CLI bundle; spn-<project>--<env> Docker socket, data directory | spawner-postgres- One image (the root
Dockerfile) runs the API and serves the dashboard (WEB_DIST_PATH=/app/web) and the CLI bundle (SPAWNER_CLI_PATH=/app/cli/spawner) on the same origin. It carries git, ssh, the Docker CLI with the compose plugin, and the installer (/app/install.sh) for updates. Its entrypoint, started as root, gives thenodeuser access to the Docker socket and the data directory, then drops root: the Prisma migrations and the API run asnode. - Postgres holds Spawner’s state, including the job queue and the sessions.
- Traefik v3 reads its routes from files Spawner writes into
<data dir>/traefik/(file provider, watched). It has no Docker socket: Spawner attaches it to each environment’s network. - The firewall (
spawner-firewall, the same image on the host’s network withNET_ADMINonly) loadsfirewall.nftevery minute: the containers of Docker’s bridge networks reach neither the metadata services of the clouds nor the host’s services but DNS, HTTP and HTTPS.install.shalso has Docker load the file before it starts (a drop-in ofdocker.service). The development stack starts it with--profile firewallonly, since it changes the host’s rules. - The stacks:
install.shwrites/opt/spawner/compose.yamlfrom the images on GHCR; the rootdocker-compose.ymlbuilds the same stack from the sources, over plain HTTP, for development and the end-to-end test.
The workspace
Section titled “The workspace”A pnpm workspace built with Turborepo. build depends on the build of the dependencies and caches dist/; lint, typecheck and test need the dependencies built; dev is not cached.
| Package | What it holds |
|---|---|
apps/api |
NestJS, Prisma, the environment engine |
apps/web |
The dashboard: Vue 3, Vite, Tailwind CSS, PrimeVue |
apps/cli |
The spawner CLI and MCP server |
packages/core |
Manifest, interpolation, compose policy and rendering, the log error filter, capacity: pure functions (no I/O but realpath), shared by the API and the CLI so that spawner up refuses locally what the server would refuse |
packages/types |
The shapes the API answers, shared by the dashboard and the CLI |
packages/utils |
Validators of git inputs (repository URLs, refs) |
The API (apps/api)
Section titled “The API (apps/api)”Modules
Section titled “Modules”- engine: the environment engine, without controllers.
pipeline.service.ts: what each job does, phase by phase (below).job-queue.service.ts: the queue in thejobstable, concurrency, recovery after a restart.git-mirror.service.ts: partial bare mirrors, one detached worktree per environment source.upload.service.ts: checks and extracts uploaded worktrees.compose-runner.service.ts:docker composeup, recreate, stop, start, down.router.service.ts: the Traefik routes and the preview protection middlewares (below).job-logs.service.ts: one log file per job, followed live over server-sent events; the logs of the last 5 jobs of each environment are kept.log-archive.service.ts: at deletion, the last 1 MiB of each service’s output, compressed, readable 7 days.git-keys.service.ts: SSH deploy keys, per repository or global.storage.service.ts: the layout of the data directory;removeTreedeletes what services wrote as root into a mounted source, through a short-lived root container of Spawner’s own image, since Spawner runs asnode.process.ts:run(), through which every external program runs, with an argument array and a minimal environment.
- auth: who makes each request (
actor.middleware.ts), dashboard sessions, passkey login (WebAuthn), the optional GitHub login, the CLI device flow, terminal tickets. - team: invitations, users and roles, each user’s account (passkeys, linked GitHub), the first admin.
- tokens: personal API tokens.
- previews: forwardAuth decisions for Traefik, the preview cookie, preview tokens for agents, share links;
wake.controller.tsserves the waiting page of sleeping and stopped environments (wake-page.ts) and wakes them up. - settings: settings changed from the dashboard (GitHub login, its secret encrypted);
limits.service.tsapplies the limits an admin changed (lifetimes, sleep, quota, memory, build guards) over the configuration. - audit (global): the audit trail, 90 days.
- projects, environments (environments and jobs), git (deploy keys, repository access test): routes only.
- terminal: the WebSocket gateway, a TTY exec session in a service through the Docker API (bash when the image has it), closed after 15 minutes without input or 4 hours. The Socket.IO server takes the WebSocket transport only, messages of 64 KiB at most, and 10 seconds to join a namespace (
TERMINAL_SOCKET_OPTIONS). Engine.IO’sallowRequestredeems the ticket and checks the origin before the WebSocket opens; a refused namespace connection, the main namespace always, closes the connection. Clients split their input into pieces of 4096 UTF-16 code units (chunkTerminalInputinpackages/core/src/terminal.ts, copied inapps/web/src/utils/terminal.ts).terminal-sessions.service.tsrecords what each session shows (2 MiB at most) for the admins. - timeline (global): the events of an environment (crash, oom, unhealthy, healthy, job started, succeeded or failed, extended) and crash loops (3 crashes or out-of-memory kills in 10 minutes).
- supervision: the collectors and what they feed (below).
- system: the memory and disk guards used before builds.
- lifecycle (global): activity, sleep and expiry, reconciliation, cleanup (below).
- health:
/api/v1/healthzand/api/v1/readyz. - meta:
/api/v1/info(the version, the domain, and the limits the CLI checks a deploy with) and the CLI download. - updates: updates from the dashboard (below).
Requests
Section titled “Requests”main.tsbootstraps the application: CORS for the dashboard’s origins, the session (stored in Postgres by connect-pg-simple), Passport, the frame headers, the dashboard (web-app.ts).common/frame-headers.tssetsX-Frame-Options: DENY,Content-Security-Policy: frame-ancestors 'none',Referrer-Policy,nosniffand, over HTTPS,Strict-Transport-Securityon every response. A page that sets its own policy must keepframe-ancestors 'none', as the dashboard’s page (dashboardPolicyinweb-app.ts, which allows the inline scripts ofindex.htmlby their hashes) and the waiting page do.ActorMiddlewarefinds the actor of each request: a bearer token (a personal token, or the installation’s bootstrap token) or the dashboard session.common/actor.tsdefines actors, roles, scopes, and who may act on an environment.common/auth.guard.tsis global: a route needs an actor unless it is marked@Public(), and the scopes listed by@Scopes().- Changes made without a bearer token must carry the
X-Spawner-Clientheader (CSRF). - Rate limits apply per user, per address without one, tighter on the login routes (
common/throttler.guard.ts). common/access.service.ts(global) holds the terminals and log streams to what their actor may still do: it reads the actor again when the team or tokens services report a change, and every 30 seconds, and closes what it no longer allows.common/secrets.service.tsholds the master secret and the keys derived from it (signed tokens, encrypted settings, the session).common/docker.service.tswraps the Docker API (Dockerode): containers by environment label, exec with stdin, logs (structured, followed, by time range), usage and stats samples, networks.common/docker-logs.tsdecodes the logs stream (multiplexed frames or TTY text) into lines with their stream and time.
Routes
Section titled “Routes”Everything is under /api/v1, in the modules of apps/api/src/modules/, but one redirect: /api/auth/github/callback (LegacyGithubCallbackController in auth/auth.controller.ts), the GitHub callback of earlier versions, which OAuth apps created for them still call, forwards to /api/v1/auth/github/callback. The shapes the routes answer are in packages/types, shared by the dashboard and the CLI.
| Prefix | Where |
|---|---|
/auth |
auth/auth.controller.ts (session, passkeys, GitHub, device flow, terminal tickets, whoami); previews/previews.module.ts (verify, verify-public, preview: what Traefik and the preview cookie use) |
/envs |
environments/environments.controller.ts; previews/previews.module.ts (preview tokens, share links); supervision/supervision.module.ts (events, metrics, disk) |
/jobs |
environments/jobs.controller.ts |
/projects |
projects/projects.controller.ts (with branches, manifest, variables); supervision/supervision.module.ts (usage) |
/invites, /users, /me |
team/team.controller.ts |
/tokens |
tokens/tokens.module.ts |
/settings |
settings/settings.module.ts (GitHub login, limits) |
/system |
supervision/supervision.module.ts (overview, metrics, capacity); lifecycle/lifecycle.module.ts (/system/cleanup); updates/updates.module.ts (/system/update) |
/audit, /terminals, /git |
audit/audit.module.ts, terminal/terminal-sessions.service.ts, git/git.controller.ts |
/info, /cli/spawner, /healthz, /readyz, /wake |
meta/meta.module.ts, health/health.module.ts, previews/wake.controller.ts |
The terminal is the Socket.IO namespace /terminal (terminal/terminal.gateway.ts): the client sends start-terminal, terminal-input, terminal-resize and stop-terminal, the server terminal-output, terminal-error and terminal-exit.
Database
Section titled “Database”PostgreSQL through Prisma (apps/api/prisma/schema.prisma, migrations in apps/api/prisma/migrations/, applied at startup).
| Table | What it holds |
|---|---|
users |
Name, role (admin or member), active flag, WebAuthn user handle |
identities |
External logins of a user (GitHub) |
passkeys |
WebAuthn credentials: public key, counter |
invites |
One-time links, by SHA-256; with user_id, a new passkey for an existing user |
api_ |
Personal tokens: prefix, SHA-256, scopes, project, expiry, revocation, and the token they were created with |
device_ |
CLI logins waiting for approval |
share_ |
Guest links to an environment’s previews, by SHA-256 |
audit_ |
The audit trail |
sessions |
Dashboard sessions (connect-pg-simple) |
projects |
Slug, name, repository, default branch, rootDir (where .spawner/ is, in a monorepo), allowPublic (exposures with auth: none), allowAlwaysOn (idle: never), sourceRepos (the repositories other sources may come from) |
project_ |
Variables of the project’s compose files; secret values encrypted with the master secret |
environments |
Slug, status, phase and error of the last failure, owner and token name, manifest, expiry, last activity. A deleted environment keeps its row (deleted_at); its slug is unique among live environments through a partial index |
environment_ |
What each source runs (git ref and commit, or upload digest and size), and whether its code is still on disk |
exposures |
Name, service, port, host and entrypoint of each URL |
jobs |
The queue and its history: type, status, phase, error, error code, payload, who asked |
environment_ |
The timeline of each environment, 30 days |
metric_ |
CPU and memory per minute, of an environment (each service in details), Spawner, the other containers or the host; 48 hours |
metric_ |
15-minute averages and maxima of the points, 30 days |
disk_ |
Disk measures and their breakdown per environment, 30 days |
terminal_ |
Who opened a terminal where, how it ended, the size of its recording; 30 days |
settings |
Key-value store: settings from the dashboard, the state of an update |
The engine
Section titled “The engine”Every change is a job, run in order per environment, one at a time per environment. Builds (create, update) are limited by SPAWNER_BUILD_CONCURRENCY; stop, start, sleep, wake and delete run alongside. A create or an update goes through:
- preparing: the memory and disk guards (a build waits up to two minutes for them, then fails with the code
capacity), then the sources: a git worktree at the ref, or an upload checked entry by entry (no absolute paths,.., links leaving the archive, devices or hard links). The project’s own source comes first, and its manifest is read before the others: public exposures,idle: neverand other repositories are refused unless the project allows them, so that nothing else is cloned for a branch that names a repository the project does not list. - validating: the compose policy, the interpolation with the project’s variables, the limits. Each issue is logged with its path and a hint, and the Dockerfiles are checked for layers that would copy the code before the dependencies.
- building:
docker compose up -d --build --wait(withfresh,down --volumesfirst). On an update, the services that mount files of a source are recreated afterwards, since Compose would keep them on the replaced directory. - seeding: on a create, or with
freshorreseed. - routing: Traefik joins the environment’s network, and the routes file is written.
The environment ends ready with an expiry, or failed with the phase and the error (and an expiry if it had none); the timeline records the start, the end and the duration of each job. After a successful deploy, the images the update replaced are removed, and so is the code of the sources that no service mounts and no env_file lives in: only the build needed it, and every rebuild checks out or receives the sources again.
A delete archives the services’ logs, then removes the routes, runs compose down --volumes, and removes the project’s images, the worktrees and the environment’s directory.
Routing
Section titled “Routing”router.service.ts writes _spawner.yaml (the dashboard and the preview protection middlewares) and one file per environment. Traefik never resolves a bare name: it reaches Spawner as spawner.spawner-core and each exposed service as <service>.<compose project>_default, and joins environment networks with a lower gateway priority (GwPriority -1) than spawner-core. Docker’s embedded DNS answers a bare name from the first of a container’s networks that knows it, and registers service names, aliases and hostnames there: hence the naming rules of the compose policy.
- Team URLs use the
spawner-preview-gatemiddleware, public URLsspawner-public-gate: a forwardAuth toGET /api/v1/auth/verify(orverify-public, which lets everything through), with only the Accept, Cookie, X-Spawner-Preview, Origin and Access-Control-Request-Method headers. Spawner answers with the request’s cookies minus its own, which Traefik passes on (authResponseHeaders: Cookie) instead of the original ones; the preview header is removed too. Security gives the order of the checks. - Sleeping and stopped environments are routed to Spawner’s waiting page (
publishPlaceholder): Traefik’sreplacePathto/api/v1/wake, still behindspawner-preview-authfor team URLs, which keeps the cookies and the preview header for the waiting page to check access again. - At startup, the awake environments without a pending job are published again, so that route files of an older version and a recreated Traefik catch up.
An environment is ready once Traefik serves its hosts.
Lifecycle
Section titled “Lifecycle”activity.service.tsrecords the last activity of an environment, at most once a minute: a request let through by forwardAuth, a deploy, a start or a wake-up, a command, logs read, a terminal, a preview token.lifecycle.service.ts, every minute: an awake environment (readyordegraded) idle for longer than its idle time gets asleepjob (its status issleepingfrom the start, its URLs lead to the waiting page, then its containers stop); expired environments are deleted, by the actor “Spawner (expired)”. A visit to a team URL of a sleeping environment queues awakejob when the server has the memory; a wake-up recreates the containers from their images if they are gone.reconcile.service.ts, at startup and every minute: an awake environment with a service exited, restarting or unhealthy isdegradedwith the reason, andreadyagain once all run; one whose containers are gone, or left in a transitional status without a job, fails.cleanup.service.ts: what deleted environments left (containers, volumes, networks, images, routes, directories), images of previous builds that no container runs, and uploads older than a day go automatically. Resources labelled for environments this installation does not know, and unused git mirrors, wait for an admin (POST /api/v1/system/cleanup). Spawner never runs a global prune.- Creating an environment needs the person’s quota (409, code
quota) and the capacity for a typical environment of the project (503, codecapacity); starting and waking need the memory.
Supervision
Section titled “Supervision”metrics-collector.service.ts: every 30 seconds, one-shot Docker stats of every container (environments, Spawner’s own Compose project, other containers) and the host from/proc; one point per minute and scope inmetric_points; the last sample kept in memory for the pages.docker-events.service.ts: the Docker events of environment containers (die, oom, health_status) into the timeline, reconnecting withsince.disk.service.ts:docker system dfevery 15 minutes and after each job, attributed to the environments (disk.ts).usage.service.ts: the charts (1 hour to 30 days, from points or rollups), the system overview and its alerts, the capacity, the usage of a project.retention.service.ts: 15-minute rollups, and the purges (points 48 hours; rollups, disk, events and terminals 30 days; deleted environments 7 days).
Updates
Section titled “Updates”releases.ts (pure) compares versions and picks the newest update a server may take. updates.service.ts reads the list of releases every 6 hours (SPAWNER_RELEASES_URL, file:// in tests). On request, it reads the digest the release’s install.sh names (IMAGE_DIGEST; a list of releases without that asset gives the tag), downloads the image by that digest and starts spawner-upgrade, a container of that image that runs its /app/install.sh --upgrade as root, on the host network, with the Docker socket, the installation directory (from the Compose label working_dir) and the data directory. The run is kept in settings (update.run) and settled by whichever Spawner comes up: succeeded, or failed when the installer went back to the previous version. Only a container of the Compose project spawner, running a release image tagged with its own version (pinned by digest or not), can update itself.
Naming
Section titled “Naming”- Compose project:
spn-<project>--<env>; containers, volumes and thedefaultnetwork follow Compose’s naming. - Labels on every service:
dev.spawner.env(environment id),dev.spawner.project,dev.spawner.env-name,dev.spawner.service. - Hosts:
<env>--<project>.<preview domain>for the entrypoint,<exposure>--<env>--<project>.<preview domain>for the other exposures. - Slugs: projects up to 20 characters, environments up to 29, exposures up to 10.
The data directory
Section titled “The data directory”<SPAWNER_DATA_DIR>/ mirrors/<hash>.git/ bare partial mirror of a repository, shared envs/<id>/src/_primary/ the project repository: git worktree or upload envs/<id>/src/<source>/ the other sources envs/<id>/compose.rendered.yaml traefik/<id>.yaml routes of an environment traefik/_spawner.yaml the dashboard and the preview middlewares jobs/<id>.log job log; the last 5 jobs of each environment archives/<id>/<service>.jsonl.gz a deleted environment's last logs (7 days) terminals/<session>.log terminal recordings (30 days) uploads/ archives waiting for their job home/ HOME and working directory of git and compose keys/ deploy keys and known_hosts secret.key master secret, when SPAWNER_SECRET is unsetMirrors are partial clones (--filter=blob:none); an archive keeps the last 1 MiB of each service’s output, compressed.
It is mounted at the same path inside the Spawner container: the compose files Spawner renders use these paths.
The dashboard (apps/web)
Section titled “The dashboard (apps/web)”Vue 3 with the Composition API (<script setup> everywhere), Vue Router, Pinia, Tailwind CSS and PrimeVue.
services/api.tscalls the API, typed with@spawner/types, and adds theX-Spawner-Clientheader to every change.stores/auth.tsholds the session; navigation guards send anonymous visitors to the login page. Pages poll while a job runs.- Passkeys go through
@simplewebauthn/browser. - The interface hides what the API would refuse (
canManageinutils/environment.ts); the API decides. - The terminal is a Socket.IO client in
components/XtermTerminal.vue, on the dashboard’s origin.
The look comes from one set of tokens, so the light and dark themes cannot drift apart:
styles/tokens.cssdefines the colors as CSS variables, light by default and dark underhtml.dark; every text color keeps 4.5:1 on its surface. The accent is the brand violet#574b89in light and the logo’s#6e54ffin dark. It also holds the sizes: a type scale (--fs-2xsto--fs-2xl) and the heights of controls (--control-sm,--control,--control-lg), so the whole interface grows or shrinks from there.style.cssholds the component classes (btn,card,badgeand itstone-*,table,alert,chip,tabs,console…) on top of Tailwind, whose colors (bg-surface,text-fg-3,border-line…) and text sizes (text-xstotext-2xl, replacing Tailwind’s) are the same tokens (tailwind.config.js). Templates use them, never Tailwind’s palette, an arbitrary size (text-[13px]) ordark:variants.theme/preset.tsis PrimeVue’s Aura in these colors, both surface scales running from light to dark as Aura expects. PrimeVue’s styles sit in a CSS layer between Tailwind’s base and utilities, so a utility class wins over them, and the unlayered rules at the end ofstyle.csssize them.composables/useTheme.tskeeps the user’s choice (light, dark or the system’s) and setshtml.dark; an inline script inindex.htmlapplies the same rule before the first paint. Charts read the token values of the theme in effect (resolveColorinutils/palette.ts).- Icons come from
lucide-vue-next; Geist, Geist Mono and the wordmark’s Chakra Petch (bold, Latin only) are bundled (Fontsource), so the dashboard loads nothing from another site.
The shell: AppSidebar.vue (navigation, the server’s domain and version, the update an admin can install, the theme, the account), AppTopbar.vue (breadcrumbs: a route’s meta.crumbs, or what a page sets with setBreadcrumbs once its subject is loaded; the update too where the sidebar is a drawer, both from useAvailableUpdate), and CommandPalette.vue (Ctrl+K or Cmd+K: environments, projects, pages and a few actions). Sign-in pages and the CLI approval have meta.layout: 'focus' and stand alone; the approval names the account the CLI will act as. Shared pieces: ActionMenu.vue (a PrimeVue popup menu with Lucide icons), SegmentedControl.vue, EnvironmentStatus.vue, SourceLabel.vue, UserAvatar.vue, Logo.vue (the mark, drawn in SVG, and the wordmark). Confirmations go through useNotification’s confirmAction, rendered by the dialog in App.vue.
Each page, with its name in the sidebar in parentheses:
| View | What it shows |
|---|---|
Home.vue (Overview) |
Live environments by status, the reader’s quota, free memory, how many more environments fit, recent environments, projects, the CLI |
ProjectList.vue, ProjectDetail.vue (Projects) |
Projects (created and edited by admins in ProjectDialog.vue); what a project uses, what one environment costs, how many more fit, its variables |
EnvironmentList.vue (Environments) |
A table of the environments, filtered by name, branch or owner, by owner, project and status, and those deleted in the last 7 days; created in EnvironmentDialog.vue (also from the palette, through ?new=1), which reads spawner.yaml to offer a branch per source and shows the URL to come |
EnvironmentDetail.vue |
Banners for crash loops, sleep, failures and running jobs above the tabs: overview (services with their memory, URLs, sources, lifecycle, share links, disk), logs (LogViewer.vue), resources (ResourcePanel.vue, UsageChart.vue), timeline (TimelinePanel.vue), jobs (JobsPanel.vue, JobLog.vue, with the phase a deploy is at or failed in), terminal; a deleted environment opens read-only, with its archived logs |
Login.vue, InviteAccept.vue |
Passkey login, GitHub when configured; an invitation link: a name, then a passkey |
DeviceApproval.vue |
Approves a CLI login: the user types the code, which the page never reads from the URL |
Account.vue (Account and tokens, in the user menu) |
Name, passkeys, linked GitHub, installing the CLI and the MCP server, API tokens |
Team.vue (Team) |
Members, roles and reactivations (each confirmed first), deactivation, links for a new passkey, invitations |
Settings.vue, Audit.vue, GitSettings.vue (Settings, Audit, Git keys) |
Limits (LimitsSettings.vue), GitHub login; the audit trail and terminal recordings; deploy keys |
SystemOverview.vue (System) |
Alerts, the host now and over time, the disk breakdown, capacity per project, every container, updates, cleanup |
The CLI (apps/cli)
Section titled “The CLI (apps/cli)”One bundle, dist/spawner.cjs, made by esbuild: CommonJS so that it runs saved without an extension, minified. Every dependency is a dev dependency, so nothing is installed at runtime.
src/main.ts,src/cli.ts: the commander program. Each action gets{ output, cwd, ctx }, returns its exit code, and never callsprocess.exit.src/runtime.ts: the Node.js version check, andfindProgram, the absolute path of a program in the absolutePATHentries, by whichgitand the browser opener run. A bare name would be looked up in the worktree first on Windows, and relativePATHentries resolve against it everywhere. On Windows,NoDefaultCurrentDirectoryInExePathis also set in the CLI’s own environment before anything starts.src/ops/: the operations, shared by the commands and the MCP server:up.ts(local check with the project’s permissions and variables, packing, create or update, wait),envs.ts(status with the timeline and crash loops, list, stats, capacity, url, share, stop, start, down, extend),logs.ts,exec.ts,auth.ts(device login, whoami, logout, tokens),init.ts,shell.ts(the Socket.IO terminal).src/mcp.ts:spawner mcp, the MCP tools over the same operations, on stdio.src/context.ts: the server connection, the target (project fromspawner.yamlor--project, environment from the branch), waiting for jobs;ensureAwakewakes a sleeping environment beforeexec,shell,urlandlogs --follow.src/archive.ts: what is sent (git ls-files, default excludes,upload.include) and the tar.gz (no hard links, symbolic links checked).src/check.ts:spawner.yamland the compose file validated with@spawner/coreand the server’s limits (GET /api/v1/info).src/config.ts:~/.config/spawner/credentials.json(mode 0600),SPAWNER_URLandSPAWNER_TOKEN.src/errors.ts:CliErrorand the exit codes.
Messages for people go to stderr and results to stdout (only JSON with --json); errors carry a stable code and a hint; external programs run through execFile with argument arrays.
Releases
Section titled “Releases”scripts/release.sh versions and tags a release, and a tag starts .github/workflows/release.yml: images on GHCR for amd64 and arm64, the CLI on npm through trusted publishing, and a GitHub release with install.sh (the digest of the image written into it), the CLI bundle and their checksums; the images, install.sh and the bundle get build provenance attestations. See scripts.