A v1 manifest service gains a third image source next to buildpacks and
BYO image: — `dockerfilePath: services/api/Dockerfile`. This is the
monorepo path: one repository, several services, each built from its own
Dockerfile with the repo root as the shared build context.
- spec: new service key (camel + snake alias), validated relative-only
(no `..`/absolute), mutually exclusive with `image` and `containers`,
rejected on db services; schema docs copy kept in sync.
- resolve/plan: the path rides the create_app payload (with autoDeploy)
and shows in the step description; a changed path plans as an app
update; drift_pair tracks it against the stored build config.
- apply: create clones the project's repository into the managed apps
dir (idempotent — an existing checkout is reused) and writes the same
git-deployment + build config the import wizard does, so the existing
deploy pipeline (push webhook, per-service autoDeploy) takes over
unchanged. Source resolves from the stored manifest's provenance, else
a sibling app's git deployment; neither is a plan-time blocker
(dockerfile_no_source). Remote targets refuse like the other appliance
features ('a Dockerfile build' joins _service_features).
- scaffold: a dockerfile-built app round-trips its build config instead
of leaking the built image as a BYO `image:` declaration.
- import wizard: a v1 manifest with dockerfilePath seeds
build_method=dockerfile + the path.
Tests: 10 new (spec exclusivity/traversal + plan/apply/blocker wiring);
manifest suite 96/96, full backend suite green except 3 pre-existing
test_server_time_timezone failures also present on a clean tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 KiB
serverkit.yaml — Declarative Service Manifest
A repository can carry a single serverkit.yaml (or serverkit.json) that
declares everything you would otherwise click into existence in the panel:
services, databases, disks, backups, environment variables, cron jobs and
domains. ServerKit parses it, diffs it against live state, and applies the
difference — at import, on push (per-service autoDeploy), or on demand.
The manifest is an optional overlay. Panel-first users see zero change; the imperative APIs stay authoritative for apps without a manifest.
Editor support
Add a $schema header comment so editors give you validation and autocomplete:
# yaml-language-server: $schema=https://serverkit.ai/serverkit-yaml.schema.json
version: 1
The JSON Schema ships in the repo at docs/serverkit-yaml.schema.json.
Versioning
version: 1 is the discriminator that selects the declarative multi-service
spec. A file without version: 1 is a legacy flat manifest and keeps behaving
exactly as before — no breaking change, ever, to the v0 shape. Legacy files
use flat build/deploy keys and describe a single service.
Key casing
camelCase is canonical in this document and the schema. snake_case aliases
are accepted everywhere (buildCommand == build_command,
healthCheckPath == healthcheck_path, fromSecret == from_secret, …).
Full example
# yaml-language-server: $schema=https://serverkit.ai/serverkit-yaml.schema.json
version: 1
server: vps-frankfurt # optional fleet target (Phase 5); omit for local
services:
- name: api
type: web
runtime: python
buildCommand: pip install -r requirements.txt
startCommand: gunicorn app:app
port: 8000
healthCheckPath: /health
autoDeploy: true
envVars:
- key: DATABASE_URL
fromService: { name: db, property: connectionString }
- key: STRIPE_KEY
fromSecret: stripe_prod
- key: SESSION_SECRET
generate: true
- key: LOG_LEVEL
value: info
disks:
- name: uploads
mountPath: /data/uploads
backup: { schedule: daily, retain: 7 }
cron:
- schedule: "0 3 * * *"
command: python manage.py cleanup
- name: db
type: postgres
version: "16"
disk:
size: 10GB
backup: { schedule: daily, retain: 7 }
domains:
- host: api.example.com
service: api
ssl: auto # best-effort — SSL stays optional, never blocks
Top-level keys
| Key | Type | Notes |
|---|---|---|
version |
1 |
Required. Selects the declarative spec. |
server |
string | Optional default fleet target. A service may override it. |
project |
string | Optional project name; defaults to the repository name. A manifest maps to one Project. |
envVars[] |
list | Manifest-level (project-scoped) variables applied to every service. |
services[] |
list | The services that make up the deployment. |
domains[] |
list | Public domains routed to services. |
Services
Each services[] entry becomes an Application (app types) or a
ManagedDatabase (db types) inside the manifest's Project, in the default
environment.
| Key | Applies to | Notes |
|---|---|---|
name |
all | Unique within the manifest. ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$. |
type |
all | web / worker / static / docker → Application; postgres / mysql / mariadb / redis → ManagedDatabase. |
runtime |
app | Build runtime hint (python, node, static, docker). |
buildCommand / startCommand |
app | Buildpack command overrides. |
dockerfilePath |
app | Build the service from this Dockerfile in the repository (repo root = build context). Mutually exclusive with image and containers — see Building from a Dockerfile. |
port |
app | Container listen port (1–65535). Keeps its HTTP/nginx semantics. |
ports[] |
app | Typed L4 publishes (appliance tier) — see Ports. |
healthCheckPath |
app | Used for health checks and the zero-downtime restart gate. |
autoDeploy |
app | true auto-applies on a changing push; otherwise the manifest flips to pending. |
cpu / memory |
app | Resource limits (best-effort). |
version |
db | Engine version (e.g. "16"). |
server |
all | Per-service fleet target override. |
envVars[] |
all | Per-service environment variables. |
disks[] |
app | Persistent volumes. |
disk |
db | Single primary disk for a db service. |
cron |
app | One object or a list — scheduled jobs. |
Ports (appliance tier)
The scalar port is the HTTP door: it stays behind nginx, on a loopback bind,
with a vhost — unchanged. Real infrastructure speaks more than HTTP, so
ports[] is the raw L4 escape hatch: media/UDP, VoIP, brokers, DNS.
services:
- name: media
type: docker
ports:
- { port: 10000, protocol: udp, expose: public } # 0.0.0.0:10000:10000/udp + firewall
- { port: 8443, containerPort: 443, expose: local } # 127.0.0.1:8443:443 (behind nginx)
| Field | Default | Meaning |
|---|---|---|
port |
— | Host port to publish (required). |
containerPort |
= port |
Container port to publish to. |
protocol |
tcp |
tcp or udp. |
expose |
public |
public binds 0.0.0.0 and opens the firewall; local binds 127.0.0.1 only. |
A public port renders a direct 0.0.0.0 docker publish and opens a firewall
rule on the panel host, recorded so deleting the app closes it again. ports[]
persist as JSON on the app row; a plain HTTP app declares none and keeps using
port.
The honesty rule — plan-time blockers
The plan tells you what the target can't provide, and apply refuses rather
than deploying something that silently won't route. Blockers are distinct from
advisory issues; there is no force flag — clear the cause and re-apply.
| Blocker | Fires when |
|---|---|
port_conflict |
A public host port is already bound on the panel host. |
remote_target |
A service with appliance features targets a remote server: — remote appliance apply is deferred; run it on the panel host. |
observed_server |
The target server is observed (read-only); ServerKit won't mutate it. |
firewall_undetected |
Public ports are declared but the host firewall state couldn't be determined. |
A manageable-firewall-simply-absent case is a warning, not a blocker: the port is still published, just not firewall-managed.
Environment variables
Each entry has a key plus exactly one value source:
| Source | Meaning |
|---|---|
value: <literal> |
A non-secret literal. Secrets never live in the manifest. |
fromSecret: <name> |
Reference to a vault secret by name. Resolved at injection time; the value never lands in the env row and masking stays intact. A missing secret is a plan-time error. |
fromService: { name, property } |
Reference to a sibling service's connection property. For db services: connectionString, host, port, database, username, password. For app services: host, port, url. |
fromServer: { property } |
The service's own advertised identity — publicIp or hostname. publicIp is what a NAT'd media/WebRTC bridge must advertise to clients. Resolves against the target server (or the panel host); a missing IP is a plan-time blocker (fromserver_no_ip). |
generate: true |
On first apply ServerKit generates a random value, stores it as a vault secret, and rewires the entry as a fromSecret reference — a committable manifest with zero secrets in git. |
Addressing
- In a unit — containers reach each other by their short name on the unit's private (compose default) network.
- Across apps — a manifest app resolves a sibling app by its service name
(
fromService: { name, property: host }). ServerKit attaches manifest-generated projects to a shared externalserverkitdocker network (created idempotently viaDockerService.ensure_network), so that name resolves at runtime. - Public IP —
fromServer: { property: publicIp }(or the${SERVER_PUBLIC_IP}template magic var) binds the host's advertised address — the WebRTC/NAT need.
Static egress IPs are out of scope. Advertising an inbound IP is container config; pinning the source IP of outbound traffic is network infrastructure (secondary IPs, SNAT) that lives below the manifest.
Disks & backups
disks:
- name: uploads
mountPath: /data/uploads
size: 5GB # recorded; not enforced by the local driver
backup: { schedule: daily, retain: 7 }
disks[] become AppVolume rows; size is recorded as the volume's
declared_size cap (not enforced by the local driver). A backup: block
becomes a BackupPolicy on the files target that resolves the live host
mountpoint of the named docker volume — so the backup captures the real bytes,
not the empty in-container path. schedule is one of
hourly / daily / weekly / monthly; retain is the number of backups to
keep.
Building from a Dockerfile
A service has exactly one image source: a buildpack (runtime +
buildCommand/startCommand), a ready-made image:, or a Dockerfile in the
repository. dockerfilePath is the third path — the monorepo story: one
repo, several services, each built from its own Dockerfile.
services:
- name: api-worker
type: worker
dockerfilePath: services/api/Dockerfile
autoDeploy: true
- name: mail-worker
type: worker
dockerfilePath: services/mail/Dockerfile
autoDeploy: true
- Context is the repo root. The path is relative to the repository;
absolute paths and
..segments are validation errors. Every service built from the same repo shares the same context, so Dockerfiles canCOPYshared code. - Source resolution. Apply clones the project's repository into the managed
apps directory and writes the same git-deployment + build config the import
wizard does — the normal deploy pipeline (including the push webhook and
per-service
autoDeploy) takes over from there. The repository is the stored manifest's provenance (recorded at import / on push), or, failing that, the git deployment of a sibling app in the project. Neither on record is a plan-time blocker (dockerfile_no_source). - Mutually exclusive with
image:(build from source or bring an image) and withcontainers:(unit containers declare ready-made images). - Panel host only for now. Like the other appliance features, a Dockerfile
build on a remote
server:target is a plan-time blocker (remote_target). - Drift. A changed
dockerfilePathshows up in the plan as an app update and rewrites the build config on apply.
BYO image & host requirements
image: is a first-class peer of the buildpack path — declare a ready-made
image instead of building one. registry: names a private registry to
authenticate against before pulling; an unknown or uncredentialed registry is a
plan-time blocker (registry_credential). ECR registries authenticate via a
key-pair exchange, so they need no stored secret.
hostRequirements: exposes the elevated compose fields real appliances need:
services:
- name: vpn
type: docker
image: ghcr.io/acme/vpn:1.2
registry: acme-ghcr
hostRequirements:
capAdd: [NET_ADMIN]
sysctls: { net.ipv4.ip_forward: "1" }
devices: [/dev/net/tun]
kernelModules: [wireguard] # advisory /proc/modules check only
Every host requirement is listed in plain words in the plan and written to a
manifest.host_requirements audit line on apply — privileged is a big hammer
and it never applies silently. kernelModules are advisory: an unconfirmed
module is a warning, never a silent pass, and is unverifiable off-Linux.
Multi-container units
Some services are not one image — a web frontend, a signaling process, a media
bridge. A containers: map makes ONE service a unit: one Application, one
compose project, one private network (the compose default), with health-gated
start order. It is mutually exclusive with the buildpack keys (runtime,
buildCommand, startCommand).
services:
- name: meet
type: docker
containers:
web:
image: jitsi/web:stable
ports:
- { port: 8443, containerPort: 443, expose: local }
dependsOn:
- { service: prosody, condition: healthy }
healthCheck: { httpPath: /, interval: 30s, retries: 5 }
disks:
- { name: web-config, mountPath: /config, size: 1GB, backup: { schedule: daily, retain: 7 } }
prosody:
image: jitsi/prosody:stable
bootstrap: { command: "/opt/gen-config.sh", timeoutSeconds: 120 }
healthCheck: { cmd: "prosodyctl status" }
Each container takes the same vocabulary as a service:
image / registry / ports / disks / envVars / bootstrap /
hostRequirements / healthCheck (cmd → CMD-SHELL, httpPath → a wget
probe) / dependsOn ({ service, condition: healthy|started }, validated
against sibling containers and checked for cycles). Container names resolve on
the unit's private network; container_name is {unit}-{container}. A
container's disks become per-container named volumes
({unit}-{container}-{disk}), so two containers can both mount /config
without colliding.
First-boot bootstrap
Some appliances generate a config/certificate tree exactly once. A compose init
container gets left behind; an entrypoint reruns every deploy. bootstrap runs
the command once, via docker compose run --rm, after the volumes exist and
before the first up:
services:
- name: prosody
type: docker
bootstrap: { command: "/opt/bootstrap/generate-config.sh", timeoutSeconds: 120 }
Success flips Application.bootstrap_done, so it never re-runs; a failure is a
visible, retryable apply-step error. To deliberately run it again (a fresh
config generation), POST /api/v1/manifests/bootstrap/reset with the app name
typed in confirm.
Domains & SSL
domains:
- host: api.example.com
service: api
ssl: auto
ssl: auto is best-effort: certificate failure degrades to a warning and
never blocks the deploy or the domain attach. HTTPS is optional in ServerKit,
period. Use ssl: off for HTTP only.
Apply model
- Explicit apply by default.
plan(dry-run) →applyis the base flow. A dry-run is always available and is the default UX. autoDeploy: trueopts a service into auto-apply on a changing push.- Every apply is logged, runs inside a
DeploymentJob, and snapshots config before and after — the same auto-apply-sandboxed-logged trust model as the rest of ServerKit. - Secrets never live in YAML. The manifest is committable by construction.
Out of scope
The manifest is an app manifest. It does not declare multi-replica / load-balanced / blue-green services, non-app server resources (firewall rules, global nginx, system packages), or act as a general-purpose IaC engine. Live panel state is the state; the manifest is the desired state.