Skip to main content
app.yaml is the declarative spec Levelrail reads from your repo: services, their build and runtime settings, and the managed databases they use. The control plane validates every field below against the JSON Schema embedded in it (internal/spec/schema/app.schema.json). An unknown key is an error, not something that gets quietly ignored. Levelrail looks for the file under these names, in this order: app.yaml, app.yml, deploy.yaml, deploy.yml, then the product’s own name with a .yaml or .yml extension. The generic names come first so a repo that already committed app.yaml keeps working.

Full example

A prebuilt image needs no build block: image: ghcr.io/your-org/web:1.0 on the service is shorthand for build: { type: image, image: ... }.

Field reference

Top level (Spec)

Service (an entry under services)

Build

Egress

Opt-in outbound allowlist for one service. Without it the service’s outbound traffic is unrestricted. Not valid with build.type of static or compose.

Volume (an entry under volumes)

Exactly one of name or hostPath must be set:
  • name: declares a named Docker volume (scoped to this service)
  • hostPath: a bind mount of a real directory on the node where the service runs
Constraint: Two volumes on the same service can never share a path.

Health

Probe

A probe is either an HTTP(S) request (set path) or a command run inside the container (set exec), never both.

Health checks

Readiness gates a deploy’s cutover: the new container has to pass it before the old one is retired. Liveness runs on every reconcile pass against a running container and restarts it after failures consecutive failures. An HTTP probe needs a port; an exec probe does not, so a worker with nothing listening can still have one.
Every probe result lands in the app’s Ready condition (reason ReadinessFailed, RunningNotReady, LivenessDegraded, and so on), and the message says exactly what failed, for example:
  • GET https://127.0.0.1:32768/health returned 302 to /login; set follow_redirects or expected_status (currently 200-299)
  • GET https://127.0.0.1:32768/ : TLS verification failed: x509: certificate signed by unknown authority; set tls_skip_verify for a self-signed certificate
  • exec "redis-cli -p 6390 ping" exited 1: Could not connect to Redis at 127.0.0.1:6390: Connection refused
  • GET http://127.0.0.1:32768/healthz timed out after 2s
The same settings are editable in the dashboard (app, then Health), with levelrail-cli apps health set, and through PUT /api/v1/apps/{name}/health. A Compose file’s healthcheck: is translated into a readiness probe: a curl/wget command becomes an HTTP(S) probe that keeps the tool’s own semantics (curl -L follows redirects, curl -f accepts any status below 400, -k/--no-check-certificate skip TLS verification), start_period widens the readiness budget, and any other command (pg_isready, redis-cli ping, mysqladmin ping) becomes an exec probe. A bare /dev/tcp connect check is not translated.

Resources

LoadBalancer

All fields are optional. Durations are Go duration strings such as 500ms, 5s or 2m. See Load balancing for behavior.

Hooks

Shell commands the reconciler runs inside the service’s own container via the Docker Engine API’s exec facility (sh -c <command>), not by shelling out. Rules
  • At least one of preDeploy/postDeploy must be set; an empty hooks: {} block is rejected.
  • With more than one replica, each hook runs exactly once per deploy (against the first replica), not once per replica. Running a database migration once per replica would be wrong, even though every replica gets a fresh container from the same image.
  • The most recent outcome of each hook (exit code, captured output) is queryable via GET /api/v1/apps/{name}/hook-runs.

Job (an entry under jobs)

See Scheduled jobs for how runs, retries and catch-up behave.

EnvVar (an entry under env)

Each value under env is either a plain string scalar (a literal value) or an object with these fields. The object form must set at least one of from, secret, or vault. vault is mutually exclusive with both from and secret: a given env var resolves its value from exactly one source.

VaultRef (external secrets integration)

The value is read fresh from Vault immediately before the container is created and is never persisted by Levelrail.If Vault is unreachable, not configured, or the secret/field doesn’t exist, the deploy fails loudly rather than starting the container with the variable empty.

Database (an entry under databases)

Backup

Bind addresses and exposure

Every published port (a service’s port/host_port, and a managed database’s public-access port; see managing databases) binds to bind_address, resolved by internal/bindaddr.Resolve:

Default behavior

private is the default for anything created after this field shipped. A service or database that was already publicly exposed before the field existed was backfilled to public and keeps that exposure. The private default only applies to a newly created resource when a redeploy or public-access change leaves bind_address unset.

Validation

spec.Parse (internal/spec/spec.go) runs two layers. Both must pass before a caller sees a Spec.

Layer 1: Structural shape

Checked against the embedded JSON Schema at internal/spec/schema/app.schema.json (compiled and cached by compiledAppSchema in internal/spec/schema.go).Coverage:
  • Required fields
  • Enums
  • String patterns (durations, memory sizes)
  • Numeric ranges
  • Rejection of unknown keys
YAML is decoded generically and round-tripped through JSON so the schema validator sees the same shapes as real JSON input.

Layer 2: Semantic rules

Checked by (*Spec).Validate in internal/spec/validate.go for anything the schema can’t express because it depends on multiple fields.Names
  • Service and database names must match ^[a-z][a-z0-9-]*$.
  • A domain can only be claimed by one service in the whole spec.
Build configuration
  • build.path is required when build.type is compose.
  • build.image is required when build.type is image and rejected for any other type. build.path and build.baseDirectory are rejected for image, and build.baseDirectory is rejected for compose.
  • build.baseDirectory must be relative and must not escape the repository root.
  • build.args is only meaningful when build.type is dockerfile. Set with any other build type, it is rejected rather than silently ignored.
Port configuration
  • port is required unless build.type is static.
  • port is forbidden when build.type is static.
Strategy
  • strategy, if set, must be one of the three known values (a redundant check behind the schema’s enum; Validate is documented as safe to call on a hand-built Spec that never went through schema validation).
Service labelsChecked by ValidateLabels (internal/spec/labels.go):
  • At most 32 labels per service
  • Keys up to 255 characters
  • Values up to 4096 characters
  • No empty keys
  • No key starting with the reserved prefix platform-reserved. (kept open for the platform’s own bookkeeping labels)
Resource limits
  • resources.swapMemory requires resources.memory to also be set.
  • resources.swapMemory must be at least resources.memory (checked during deploy translation in internal/deploy, since both values need byte conversion). Docker’s MemorySwap is the combined memory+swap ceiling, not swap on top of memory.
Egress
  • egress is rejected when build.type is static or compose. mode must be allowlist with at least one allow entry, each a hostname or IPv4 address with a valid port.
Dependencies
  • Each dependsOn entry must name another service in the file, not itself, and not a static or compose service. A cycle is rejected.
Hooks
  • jobs is rejected when build.type is static or compose, for the same reason as hooks. Job names must be unique within the service, the schedule must parse as a cron expression, timezone must be a known IANA zone, and durations must be positive.
  • hooks is rejected when build.type is static (no container to run a command in) or compose (a wrapper that expands into N real services; one hooks block cannot unambiguously target any of them).
  • An empty hooks: {} block is rejected at the schema layer (the schema’s own minProperties: 1).
Volumes
  • Each volume’s name (if a named volume) must match ^[a-z][a-z0-9-]*$.
  • No two volumes on the same service may share a name or the same path.
  • Each volume’s hostPath (if a bind mount) must be an absolute path.
  • hostPath is rejected under protected system paths: /, /etc, /root, /boot, /sys, /proc, /var/lib/docker, /var/run/docker.sock, or /var/run. /var/run/docker.sock is excluded by design (it’s a full container-escape-to-host-root vector; this feature doesn’t grant that capability).
  • readOnly: true on a volume with name set (not hostPath) is rejected (no meaning for a named Docker volume).

Additional guard: strict YAML parsing

spec.Parse also runs yamlUnmarshalStrict (YAML decode with KnownFields(true)) as an independent guard against struct tags and JSON Schema drifting apart. An unknown key becomes a decode error during development rather than a silently dropped field in production.

Next steps

Getting started

Deploy your first app with an app.yaml.

Deploying apps

Secrets, environment setup, rollouts and rollbacks.

Managing databases

Database configuration and backups.

Git integrations

Branch, push versus release trigger, and webhooks. None of that lives in app.yaml.
Last modified on October 12, 2026