The .spawner/ directory: spawner.yaml and the compose file
A project tells Spawner how to run it with two files in a .spawner/ directory of its repository (spawner init creates them):
spawner.yaml, the manifest: the project, its URLs, its seed, its other repositories;- a Docker Compose file,
compose.yamlby default: the services, as fordocker compose, with a few rules.
In a monorepo, .spawner/ sits in the directory of the application, and an admin gives that directory (Directory) when registering the project in the dashboard. The quickstart goes from a new server to a first environment.
spawner.yaml
Section titled “spawner.yaml”version: 1project: blog # slug of the project on Spawnername: api # this repository's sourcecompose: compose.yaml # relative to .spawner/
sources: # other repositories front: repo: git@github.com:acme/blog-front.git default_ref: develop
exposures: # the URLs; the first one is - name: web # the entrypoint service: front port: 3000 - name: api service: api port: 8000
seed: # test data, after the first start - service: api run: [php, artisan, migrate, --seed, --force]
ttl: 72h # lifetime from the last deployidle: 2h # sleep after this long unusedupload: include: [.env.preview] # ignored files to send toolimits: memory: 2g # for the whole environmentversion, project and exposures are required; every other key has a default. A key the manifest does not know is refused (spawner.yaml: Unrecognized key: "env"). spawner up checks the file before sending anything, and the server checks it again at each deploy; the refusals quoted below are the lines spawner up prints (when .spawner/ is refused).
version
Section titled “version”Required: 1, the only version. version: 2 gives spawner.yaml: version: Invalid input: expected 1.
project
Section titled “project”Required: the slug of the project, as an admin registered it in the dashboard (Projects, New project, Slug). Lowercase letters, digits and single dashes, starting with a letter, 20 characters at most; it is part of every URL.
spawner up finds the project by this slug: until an admin registers it, it stops with project "blog" is not registered on https://spawner.preview.example.com.
Optional, app by default: the name of this repository’s own source, the one holding .spawner/. It gives the variable SPAWNER_SRC_<NAME> (SPAWNER_SRC_APP by default) and the --ref <name>=<branch> of spawner up. Same rules as a project slug, and different from the names under sources.
compose
Section titled “compose”Optional, compose.yaml by default: the path of the compose file, relative to .spawner/, inside the project’s directory. ../docker-compose.yml uses a file at the root of the project, provided it follows the rules. An absolute path is refused: spawner.yaml: compose: compose must be a path relative to .spawner/.
sources
Section titled “sources”Optional: the other repositories of the application, when it spreads over several. Each key is a source name (same rules as a project slug, 20 characters at most), with:
repo(required): the repository, over SSH (git@github.com:acme/blog-front.git) or HTTPS (https://github.com/acme/blog-front.git, public repositories only). A private repository needs a deploy key: an admin generates one per repository on the Git keys page, then adds its public key to the repository, read-only.default_ref(optional,mainby default): the branch, tag or commit deployed whenspawner upasks for nothing else.--ref front=<branch>takes another one from git,--source front=<directory>sends a local worktree.
Each repo must be one of the project’s source repositories, which an admin lists in the project’s settings (Source repositories), written the same way; otherwise the deploy is refused, by spawner up before anything is sent: spawner.yaml: sources.front.repo: source "front" comes from git@github.com:acme/blog-front.git, which is not among the source repositories of this project. A branch thus never makes Spawner clone a repository the project does not use, with its deploy keys.
Each source is checked out next to the others; SPAWNER_SRC_<SOURCE> gives its path, for build contexts and mounts. A name that breaks the rules gives spawner.yaml: sources.<name>: Invalid key in record.
exposures
Section titled “exposures”Required, from 1 to 10: the URLs of the environment, each routed to a port of a service.
name(required): lowercase letters, digits and single dashes, starting with a letter, 10 characters at most, unique. It names the URL and the variablesSPAWNER_URL_<NAME>andSPAWNER_HOST_<NAME>.service(required): a service of the compose file, attached to itsdefaultnetwork.port(required): the port the service listens on inside the environment, from 1 to 65535. Nothing is published on the server.entrypoint(optional):truefor the exposure served without a prefix (see URLs). At most one says so; without one, the first exposure is the entrypoint.auth(optional):team(default) protects the URL (protected previews);nonemakes it public, for a webhook or a public page. An admin allows public URLs per project (Allow public URLs, in the project’s settings); otherwise the deploy is refused, byspawner upbefore anything is sent:spawner.yaml: exposures[0].auth: exposure "hooks" is public (auth: none), which this project does not allow. A public URL does not wake a sleeping environment: see idle.
Without exposures: spawner.yaml: exposures: Invalid input: expected array, received undefined.
Optional: the steps that fill a new environment with test data (migrations, fixtures). They run in order once its services are up: on the first deploy, then again with spawner up --reseed or --fresh (in the dashboard, Redeploy and replay the seed or Redeploy from scratch). Each step has:
service(required): a service of the compose file. It must be running: in a service that has exited, the deploy fails withservice "api" is not running.run(required): the command, as an argument array, run in the service without a shell. Call one when you need it:[sh, -c, "npm run seed && npm run fixtures"]. A string is refused:spawner.yaml: seed[0].run: Invalid input: expected array, received string.
Each step has 10 minutes; its output goes to the job log. A step that exits with another code than 0 fails the deploy, at the seeding phase. --reseed runs the steps on the data already there: write steps that can run twice, or use --fresh, which deletes the data first.
Optional: how long the environment lives after its last deploy, as a duration such as 24h, 1h30m or 14d (units s, m, h, d). Without it, the server’s lifetime applies (72 hours by default). The server’s longest lifetime (14 days by default) caps it without a message. Each deploy starts the count again, without shortening an extension; spawner up --ttl and spawner extend override it. An expired environment is deleted with its data.
A number is refused: spawner.yaml: ttl: Invalid input: expected string, received number.
Optional: how long the environment may go without activity before it goes to sleep. Without it, the server’s setting applies (2 hours by default). A duration of at least 10m, or never.
Requests to its team URLs, deploys, commands, logs and terminals count as activity. A sleeping environment’s containers stop and its data stays; the next visit to one of its team URLs wakes it up within seconds. Public URLs (auth: none) neither keep it awake nor wake it up: while it sleeps, they answer 503 (environment_asleep to an API client, a waiting page to a browser). For a webhook, use idle: never, or spawner wake before testing.
idle: never keeps the environment awake, and its memory taken. An admin allows it per project (Allow environments that never sleep); otherwise: spawner.yaml: idle: idle: never keeps the environment awake, which this project does not allow. Below 10 minutes: spawner.yaml: idle: idle must be at least 10m (or never).
upload
Section titled “upload”Optional. include lists files that git ignores but spawner up sends anyway, as globs relative to the project’s directory: .env.preview, config/*.local.php, **/*.pem. Without it, spawner up sends what git sees in the worktree, uncommitted changes included, and leaves out untracked node_modules/, vendor/ and .env files (what is sent).
limits
Section titled “limits”Optional. memory is the memory of the whole environment, which its services share (memory limits): a size such as 512m, 1.5g or 2g (1g = 1024 MiB). Without it, the server’s setting applies (2 GiB by default). The server’s maximum (4 GiB by default) caps it without a message; admins change both on the Settings page. limits takes no other key: spawner.yaml: limits: Unrecognized key: "cpus".
The entrypoint is served on <env>--<project>.<preview domain>, the other exposures on <exposure>--<env>--<project>.<preview domain>: feat-login--blog.preview.example.com, api--feat-login--blog.preview.example.com. The environment’s name comes from the branch (29 characters at most), so that the longest name still fits in one DNS label, under one wildcard certificate.
The compose file
Section titled “The compose file”A regular Docker Compose file, which Spawner reads, checks and rewrites before running it with docker compose. Example for Laravel, Next.js and MySQL, with the front in its own repository (the front source above):
services: db: image: mysql:8.4 command: # a database for previews - --skip-log-bin - --performance-schema=OFF - --innodb-buffer-pool-size=64M - --innodb-redo-log-capacity=16M - --innodb-flush-log-at-trx-commit=2 environment: MYSQL_DATABASE: app MYSQL_USER: app MYSQL_PASSWORD: app MYSQL_ROOT_PASSWORD: root volumes: - db-data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"] interval: 3s retries: 60
api: build: context: ${SPAWNER_SRC_API} dockerfile: .spawner/Dockerfile environment: APP_URL: ${SPAWNER_URL_API} FRONTEND_URL: ${SPAWNER_URL_WEB} LOG_CHANNEL: stderr # errors in spawner logs DB_HOST: db DB_DATABASE: app DB_USERNAME: app DB_PASSWORD: app depends_on: db: { condition: service_healthy }
front: build: context: ${SPAWNER_SRC_FRONT} args: NEXT_PUBLIC_API_URL: ${SPAWNER_URL_API}
volumes: db-data:- Relative paths start from the directory of the compose file,
.spawner/by default.build: ..builds the project’s directory, the same as${SPAWNER_SRC_APP};build: .would build.spawner/itself. - Build contexts, Dockerfiles,
env_fileand mounted files stay inside the sources: the project’s directory, and the repositories undersources. In a monorepo, the project’s directory is the one an admin gave (Directory), so no path reaches the rest of the repository:services.web.build.context: build context "../../.." resolves outside the environment sources. When a Dockerfile needs the root of the repository (a pnpm, Yarn or Turborepo workspace with one lock file), put.spawner/at the root and give the project the directory.. - A file the compose file uses must reach the server: committed, or named in
upload.include.spawner upchecks the paths in your worktree, so an untracked.envpasses there and fails on the server (compose.path_not_found). Anenv_fileentry may sayrequired: false.
Variables
Section titled “Variables”The file is filled in with Spawner’s variables and the project’s variables only, never with the server’s environment, which holds Spawner’s secrets.
| Variable | Value |
|---|---|
SPAWNER_PROJECT, SPAWNER_ENV |
blog, feat-login |
SPAWNER_ |
URL of the entrypoint |
SPAWNER_ |
SPAWNER_URL_API=https://api--feat-login--blog.preview.example.com |
SPAWNER_ |
the same host, without the scheme |
SPAWNER_ |
path of a source on the server, for build contexts and mounts; SPAWNER_SRC_APP (after name) is the project’s directory |
| Project variables | set by an admin on the project page (Variables): ${STRIPE_KEY} |
<EXPOSURE> and <SOURCE> are uppercased, with - turned into _. Services reach each other on the environment’s network by service name (http://api:8000), without authentication.
The syntax is Compose’s:
| Written | Gives |
|---|---|
${VAR}, $VAR |
The value; an error when the variable does not exist |
${VAR:-default}, ${VAR-default} |
default when VAR is unset or empty; without :, only when it is unset |
${VAR:?message}, ${VAR?message} |
An error saying message when VAR is unset or empty; without :, only when it is unset |
${VAR:+other}, ${VAR+other} |
other when VAR is set and not empty; without :, whenever it is set |
$$ |
A literal $ |
Every string of the file is filled in, commands and healthchecks included, so a variable of the container’s shell needs $$:
healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]With a single $, the file is refused: services.db.healthcheck.test[1]: unknown variable POSTGRES_USER (only SPAWNER_* and project variables exist; write ${POSTGRES_USER:-value} for an optional one).
Project variables fill in the file like Spawner’s: a service gets one only if the file passes it, under environment: (STRIPE_KEY: ${STRIPE_KEY}). Their names use uppercase letters, digits and _, not starting with SPAWNER_. A change applies at the next deploy. Secret values are stored encrypted, never shown again, and masked in job logs.
Spawner runs compose files from branches nobody has reviewed, so it accepts only what cannot reach the host. Security lists every key allowed, refused or set, and the limits; what you will meet most:
- no
ports: declare an exposure inspawner.yaml, and Traefik routes it; - volumes are named volumes,
tmpfs, or files mounted from a source, never host paths; buildcontexts, Dockerfiles andenv_filestay inside a source (paths);- no
privileged,cap_add,devices,network_mode,pid,ipc,security_opt,sysctls,ulimitsorcontainer_name, no external volumes or networks, noenable_ipv6: true; - under
deploy, onlyresourcesandreplicas: 1; - no labels starting with
traefik.,com.docker.ordev.spawner.; - at most 30 services, and per service 2 CPUs, 2048 processes, 1 GiB of
shm_sizeand 60 seconds ofstop_grace_period.
Service names, network aliases and hostname become DNS names on the environment’s network: lowercase letters, digits, - and _, without dots and not ending with -, 63 characters at most (domainname may hold a domain). Names starting with spawner or spn- are reserved for Spawner.
Spawner sets on every service: its memory, CPU and process limits (memory limits), no-new-privileges, no raw sockets (NET_RAW dropped), restart: unless-stopped and capped local logs. The file’s own restart and logging are replaced.
When .spawner/ is refused
Section titled “When .spawner/ is refused”spawner up checks spawner.yaml and the compose file before sending anything, with the same code and the same limits as the server, and lists every issue:
$ spawner uperror: refused before upload: services.api.ports: ports is not allowed (host ports are never published; declare an exposure in spawner.yaml) services.api.volumes[0]: bind mount source "/var/run/docker.sock" resolves outside the environment sources (paths must stay inside the repositories declared in spawner.yaml)fix these in .spawner/, then run spawner up againEach line gives the path of the key, the problem and, in parentheses, what to do; the exit code is 7. The check stops at the first stage that fails, in this order: spawner.yaml, the variables, then the policy. Once a stage passes, the next run lists the issues of the next one. With --json, each issue also carries one of the codes below (JSON output). The server checks again at each deploy, environments from git included: a refused file fails the job at the validating phase, with the same lines in its log, and goes to the audit trail.
| Code | What it means |
|---|---|
yaml.invalid |
The file is not valid YAML, or gives a key twice |
yaml.too_ |
spawner.yaml is above 64 KiB, or the compose file above 1 MiB |
manifest.invalid |
A key of spawner.yaml is missing, unknown or wrong (the keys) |
manifest.public_ |
auth: none, in a project that does not allow public URLs |
manifest.always_ |
idle: never, in a project that does not allow environments that never sleep |
slug.invalid |
A project, environment or source name breaks the naming rules |
variables.invalid |
A project variable has a name that cannot be used: an admin renames it |
interpolation.invalid |
A $ that starts no variable, or a ${ never closed: write $$ for a literal $ |
interpolation.unknown_ |
A variable that neither Spawner nor the project sets: $$ for a shell variable, a default (${VAR:-value}), or a project variable |
interpolation.required_ |
${VAR:?message} while VAR is not set |
compose.invalid |
A value of the wrong shape: a name that is not a DNS label, a service without image or build, a size or a duration that cannot be read |
compose.forbidden_ |
A key the policy refuses, such as ports or privileged: the hint says what to do instead |
compose.unknown_ |
A key the policy does not know, often a typo: the hint gives the closest one |
compose.path_ |
A path that leaves the sources: a host path, ~, .. past the project’s directory, a symbolic link pointing out |
compose.path_ |
A path inside the sources that does not exist: on the server, a file neither committed nor sent |
compose.undefined_ |
A volume, network or service used but not declared, including the service of an exposure or of a seed step |
compose.limit_ |
Above a limit: memory, CPUs, processes, services, shm_size, stop_grace_period, replicas |
compose.not_ |
An exposed service that is not on the default network: add default to its networks |
Your application behind Spawner
Section titled “Your application behind Spawner”What an application needs to run in an environment, besides its compose file:
- Listen on every interface (
0.0.0.0), on the exposure’s port, in plain HTTP. Traefik reaches the service by its name on the environment’s network and handles TLS itself. A server bound tolocalhostor127.0.0.1in its container cannot be reached, and its URL answers 502 Bad Gateway. Development servers often bind to localhost by default:next start --hostname 0.0.0.0,php artisan serve --host=0.0.0.0,vite --host 0.0.0.0. - Trust the proxy. Requests reach the application over HTTP from Traefik, with
X-Forwarded-Proto: httpsand the originalHost. An application that builds absolute URLs or sets secure cookies must trust these headers, or it writeshttp://links:$middleware->trustProxies(at: '*')in Laravel,app.set('trust proxy', true)in Express. Only Traefik and the environment’s own services reach it. - Take its URLs from Spawner:
${SPAWNER_URL}and${SPAWNER_URL_<EXPOSURE>}underenvironment:, never a host written in the code or in a.envfile. Between services, use the service name (http://api:8000): no token, no TLS, no round trip through Traefik. - Values read at build time (Next.js’s
NEXT_PUBLIC_*, Vite’simport.meta.env) go inbuild.args, since each environment builds its own image. Declare theARGafter installing the dependencies, so that the dependency layer stays shared (below). - Accept the preview hosts. A server that checks the
Hostheader refuses names it does not know: list the preview domain, such asserver.allowedHosts: ['.preview.example.com']for Vite’s development server, orALLOWED_HOSTSfor Django. - Calls from the browser to another exposure cross origins: the API must allow the front’s origin (CORS) with credentials, and a protected URL needs the team’s cookie (
credentials: 'include'infetch). Calling the API from the server side, as the Laravel example does, needs neither.
Healthchecks
Section titled “Healthchecks”A deploy runs docker compose up --wait: Spawner waits until every service runs, and every service with a healthcheck reports healthy, for 5 minutes at most (SPAWNER_START_TIMEOUT_SECONDS, configuration). Past that, the deploy fails at the building phase. A service without a healthcheck counts as started as soon as its container runs, so its environment can be ready a few seconds before the application answers.
depends_on with condition: service_healthy starts a service once the one it needs is healthy, such as an API once its database accepts connections. Docker runs the first check after interval, 30 seconds unless the file says otherwise: give a short one (interval: 2s), or start_period with a short start_interval (Docker Engine 25 or later) to check often only while the service starts.
Spawner sees what services write to stdout and stderr. Laravel writes to storage/logs/laravel.log by default: set LOG_CHANNEL: stderr. Next.js and the official nginx images already write to the standard output.
Making environments cheap
Section titled “Making environments cheap”Memory is what runs out first: a running environment holds its memory, a sleeping one only takes disk. Spawner puts to sleep what nobody uses; the project decides how much each environment costs. For example, the Laravel, Next.js and MySQL example runs in about 320 MiB of memory, and each of its environments adds about 4 MiB of images of its own: its 830 MiB of base images and dependencies are shared by all its environments.
Dockerfiles that share their layers
Section titled “Dockerfiles that share their layers”Docker stores identical layers once. An environment then only costs its own layers, provided the Dockerfile installs the dependencies before copying the code:
FROM node:22-alpineWORKDIR /app# The lock file only: the same layer for every# environment with this lock fileCOPY package.json package-lock.json ./RUN npm ci# Then each environment's own codeCOPY . .RUN npm run buildA comment goes on its own line: Docker reads a # after an instruction as one more argument.
The same goes for composer.json and composer.lock before composer install, requirements.txt before pip install, Gemfile and Gemfile.lock before bundle install. Copying the whole code first gives each environment its own copy of its dependencies, hundreds of MiB each: Spawner warns about it in the job log, and spawner up prints the warning too (not with --json or -q).
Also:
- a
.dockerignorethat leaves outnode_modules,vendor,.gitand build outputs; - the same base image tag across the services and projects of the server (
node:22-alpineeverywhere rather than three variants); - Next.js in
standalonemode copies the dependencies it needs into the build output, so into each environment’s own part: with many environments of a project, a sharednode_moduleslayer andnext startcost less in total, as in the example.
The Disk card of an environment (on its Overview tab) shows its own part and the part it shares. The code of a source is only needed to build: once an environment is built, Spawner removes it, unless a service mounts files of it or an env_file lives in it.
Databases for previews
Section titled “Databases for previews”A preview database holds test data that the seed recreates. It needs neither replication nor instrumentation, and production defaults waste hundreds of MiB per environment.
MySQL:
db: image: mysql:8.4 command: - --skip-log-bin # no binary log - --performance-schema=OFF # hundreds of MiB - --innodb-buffer-pool-size=64M - --innodb-redo-log-capacity=16M - --innodb-flush-log-at-trx-commit=2 # faster seedPostgreSQL:
db: image: postgres:18-alpine command: - postgres - -c - shared_buffers=32MB - -c - max_wal_size=256MB - -c - synchronous_commit=offsynchronous_commit=off may lose the last transactions in a crash, without corrupting the database, unlike fsync=off.
Memory limits
Section titled “Memory limits”The memory of an environment (limits.memory, 2 GiB by default) is shared by its services:
- a service with
mem_limit, ordeploy.resources.limits.memory, keeps it. Together, these limits must fit in the environment’s memory, or the file is refused (services ask for 2524 MiB of memory, the environment limit is 2 GiB); - the other services share what is left equally, 512 MiB each at most. In 2 GiB, four services without a limit get 512 MiB each, and six get about 340 MiB each; a database given
1536mleaves about 170 MiB to each of three others. When what is left comes to less than 32 MiB each, the file is refused.
CPUs and processes are shared the same way, within 4 CPUs and 4096 processes per environment (the server’s SPAWNER_ENV_CPUS and SPAWNER_ENV_PIDS): a service keeps its cpus or pids_limit (or deploy.resources.limits), and the others share what is left, 1 CPU and 512 processes each at most. More than the environment has is refused (services ask for 6 CPUs, the environment limit is 4), and so is a share below 0.05 CPU or 64 processes.
A limit reserves nothing: it decides what happens when a service leaks, an out-of-memory kill of that service on the timeline rather than the server swapping. The capacity Spawner announces counts what environments of the project really use, or the environment’s limits.memory until one has run, so generous limits on services do not lower it.
A build can take far more memory than the running application (next build may take a few GiB for a minute): builds run one at a time below 8 GiB of memory, and each waits until 2 GiB are free.