Manifest reference

The complete schema for the YAML / JSON files you pass to ring apply -f. Every field, what Ring expects in it, and what happens if you omit it.

A manifest has three top-level keys: namespaces: (optional), configs: (optional) and deployments: (required).

Runtime parity. Most fields below are honored by both runtimes. A handful are Docker-only: they are declared in the manifest, accepted by the API, and either silently ignored or rejected by the Cloud Hypervisor runtime. Each affected section flags this inline; the cross-cutting list lives on Cloud Hypervisor → Limitations.

namespaces:
  production:
    name: production

deployments:
  api:
    name: api
    namespace: production
    runtime: docker
    image: "myapp:v1.2.3"
    replicas: 3

Top level

namespaces: (optional)

A map of namespace declarations. When present, Ring creates them before processing deployments. Already-existing namespaces are silently skipped. Namespaces are also auto-created the first time a deployment lands in them, so this section is purely cosmetic, useful when you want all namespaces in a manifest to be self-documenting.

namespaces:
  production:
    name: production
  staging:
    name: staging

configs: (optional)

A map of config declarations. When present, Ring creates them after namespaces and before deployments, so a deployment that mounts one via a type: config volume can resolve it on first apply. Already-existing configs (same name + namespace) are reported as "already exists, skipping": re-applying an unchanged manifest is idempotent and never errors. The map key is internal; Ring keys the config by its name + namespace.

configs:
  nginx-config:
    namespace: production
    name: "nginx-config"
    data: '{"site.conf":"server { listen 80; }"}'
    # labels: "tier=frontend"   # optional
FieldTypeRequiredDescription
namespacestringyesNamespace the config lives in. Must match the namespace of any deployment mounting it.
namestringyesConfig name. This is what a type: config volume's source references.
datastringconditionalJSON object mapping keys to payloads, e.g. {"site.conf":"..."}. A type: config volume's key selects which entry to mount. Subject to $VAR interpolation by default (see interpolate). Required unless files is set.
filesmapconditionalMap of key -> path. Each referenced file is read at apply time and its raw contents become the value for key in the config's JSON payload. Paths are relative to the manifest. File contents are verbatim by default (not interpolated). Required unless data is set.
interpolateboolnoControls $VAR interpolation on this config's payload. Unset: inline data is interpolated, files contents stay verbatim. true: both are interpolated. false: the whole payload (inline + files) is verbatim.
labelsstringnoFree-form labels.

At least one of data or files must be present; a config with neither is rejected.

files: for keeping large payloads in their own file

Inlining a big config (an Nginx site, a full config.yaml, an HTML error page) into data means hand-escaping a string-of-JSON-inside-YAML, where newlines become \n, quotes get doubled, and the manifest becomes unreadable. files: avoids that: keep the payload as a normal file next to the manifest and reference it.

configs:
  sozune-config:
    namespace: proxy
    name: "sozune-config"
    files:
      config.yaml: ./sozune/config.yaml   # relative to this manifest

At apply time Ring reads ./sozune/config.yaml verbatim and builds data = {"config.yaml": "<file contents>"}. The file stays a real, editable YAML file, with no escaping.

data and files can be combined to merge an inline entry with file-backed ones:

configs:
  app-config:
    namespace: production
    name: "app-config"
    data: '{"feature.flag":"on"}'   # small inline entry
    files:
      config.yaml: ./config.yaml     # large file-backed entry

The two are merged into a single JSON object. Defining the same key in both data and files is an error, and when files is used a non-object data (anything that isn't a {...} JSON object) is rejected.

$VAR interpolation and file contents

By default, $VAR interpolation runs on inline data (it's a template you write by hand) but not on files contents. This matters: an Nginx site, a Prometheus rule, or a Grafana dashboard is full of literal $host, $labels, $remote_addr, and interpolating them would silently mangle the file (or worse, splice a host env var's value into the payload). So a referenced file ships verbatim.

The interpolate field overrides this per config:

interpolateinline datafiles contents
unset (default)interpolatedverbatim
trueinterpolatedinterpolated
falseverbatimverbatim
configs:
  nginx:
    namespace: proxy
    name: "nginx"
    files:
      site.conf: ./site.conf      # $host etc. kept verbatim

  templated:
    namespace: proxy
    name: "templated"
    interpolate: true             # opt in: $VAR substituted in the file too
    files:
      app.conf: ./app.conf

A manifest carrying its own configs: is self-sufficient: ring apply -f manifest.yaml creates the configs and the deployments that reference them in one pass, with no out-of-band ring config create needed.

deployments: (required)

A map of deployment declarations. The map key is internal; Ring keys the deployment by its name + namespace fields, not by the YAML key. By convention the YAML key matches the name.

Deployment fields

Required

FieldTypeDescription
namestringDeployment name. Together with namespace forms the unique identity.
namespacestringNamespace the deployment belongs to. Auto-created if missing.
runtimeenumdocker (default runtime) or cloud-hypervisor (alpha microVM runtime).
imagestringDocker image reference (Docker runtime, e.g. nginx:1.25) or absolute path to a raw disk image (Cloud Hypervisor runtime, e.g. /var/lib/ring/images/ubuntu-focal.raw). The API rejects a Docker-style reference on the CH runtime up front.

Optional

FieldTypeDefaultDescription
kindenumworkerworker (long-running) or job (one-shot). On CH, a job moves to completed when the guest powers off cleanly; the workload's exit code is not surfaced. See how-to: run a job.
replicasinteger1Number of instances. Jobs always run a single instance regardless.
commandstring list[]Override the image's entrypoint/CMD. Docker only, rejected at the API on the CH runtime.
environmentmap{}Environment variables, either plain values or secretRef references. See environment.
volumesobject list[]Volume mounts. See volumes.
portsobject list[]Host-port publishings. See ports.
labelsmap{}Key/value labels. Docker only: forwarded to Docker container labels. CH silently ignores them.
resourcesobjectunsetCPU / memory limits and requests. Semantics differ between runtimes. See resources.
health_checksobject list[]TCP / HTTP / command health probes. See health checks.
configobject{}Runtime config: image pull policy, registry credentials. Container runtimes only: every field of config is silently ignored on the CH runtime, since there is no image to pull.
networkobject{ mode: bridge }Network mode. See network. Docker only.

environment

Map of environment variables passed to the container. Values come in two forms:

  • Plain string: passed verbatim.
  • Secret reference: an object { secretRef: <name> } that resolves to an encrypted secret in the same namespace at deployment time.
environment:
  LOG_LEVEL: "info"                  # plain
  DATABASE_PORT: "5432"              # plain (must be a string in YAML)
  DATABASE_PASSWORD:
    secretRef: "database-password"   # encrypted
  JWT_SECRET:
    secretRef: "jwt-secret"

If a secretRef cannot be resolved, the deployment is marked failed and an error event is emitted (reason: SecretResolutionError). See how-to: deploy with secrets.

Variable interpolation

ring apply interpolates $VAR references in string values from your shell environment, or from a file passed with --env-file. This happens client-side, before the manifest is sent to the API:

environment:
  IMAGE_TAG: "$IMAGE_TAG"
  DEPLOY_USER: "$USER"
export IMAGE_TAG="v1.2.3"
ring apply -f deployment.yaml

Interpolation also applies to image, name, namespace, and command arguments, anywhere a string lives in the manifest.

volumes

A list of volume objects. Four types are supported:

typeSourceDescription
bindhost pathMount a directory or file from the host into the container.
volumevolume nameMount a named Docker volume (driver local or nfs).
configconfig nameMount a file rendered from a ring config entry in the same namespace.
secretsecret nameMount a file rendered from a ring secret entry in the same namespace. Always read-only.

Schema

FieldRequiredUsed byDescription
typeyesallbind, volume, config, or secret.
sourceyesallHost path (bind), volume name (volume), config name (config), or secret name (secret).
keyyes for config, ignored otherwiseconfig onlySelects which key inside the named config to mount. A config can carry multiple key/value entries; key picks one. The API rejects a config volume without key (or with empty key). Not used for secret, which has a single opaque value.
destinationyesallPath inside the container. For config and secret volumes, this is the file path the payload will be written to.
driverno (default local)volume (otherwise informational)local or nfs. Only meaningful for volume.
permissionnobind and volumero or rw. Defaults to rw for bind and volume. For config and secret, the API forces ro regardless of what you write.
volumes:
  - type: bind
    source: /var/lib/ring/postgres
    destination: /var/lib/postgresql/data
    driver: local
    permission: rw

  - type: volume
    source: app-data
    destination: /data
    driver: local
    permission: rw

  - type: config
    source: nginx-config            # name of an existing `ring config`
    key: site.conf                  # which entry inside the config to mount
    destination: /etc/nginx/conf.d/site.conf
    driver: local
    permission: ro

  - type: secret
    source: api-bearer-token        # name of an existing `ring secret`
    destination: /run/secrets/api-token
    driver: local
    permission: ro

For config and secret volumes, the source is the config's or secret's name (not its UUID), and the resource must live in the same namespace as the deployment. See Cloud Hypervisor → Volumes for runtime-specific lifecycle details.

For secret volumes specifically:

  • The whole decrypted value becomes the file contents (no key: field).
  • Containers should treat the path as read-only, since Ring forces ro.
  • If you update the underlying ring secret, the running container keeps the old value until it is restarted. Trigger a redeploy to pick up the new value.

Wire-format vs ring apply. The API DTO requires driver and permission to be present (no defaults at deserialization time). The ring apply CLI fills them in client-side before posting (local and rw respectively, except for config which becomes ro). If you POST /deployments directly with raw JSON, include both fields explicitly.

A writable named volume cannot be shared across replicas. A type: volume mount with permission: rw and replicas > 1 is rejected at validation (deployment.volumes.shared_rw_replicas): every replica would mount the same volume read-write with no cross-writer coordination, which silently corrupts data (e.g. a database). Either drop replicas to 1, or mount the volume ro, since read-only sharing is allowed.

Named volumes a deployment mounts are auto-registered as first-class volumes, so they are traceable and can be managed via the /volumes API. Anonymous volumes (from an image's VOLUME directive) are removed with their container; named volumes are never deleted by a deployment's deletion.

ports

Host-port publishings. Each entry maps a host port to a container port:

ports:
  - { published: 8080, target: 80 }
  - { published: 3000, target: 3000 }
  - { published: 5432, target: 5432, host_ip: 127.0.0.1 }
FieldTypeDescription
publishedintegerHost port
targetintegerContainer port
host_ipstringOptional. Host interface to bind published on. Defaults to 0.0.0.0 (all interfaces). Set to 127.0.0.1 to expose the port on loopback only, which is useful for a database that should reach a local reverse proxy but stay off the public network. Must be a valid IP address or ring apply rejects it.
protocolstringOptional. tcp (default) or udp. The same published port may be declared once for each protocol.

Ring forwards these to Docker's HostConfig.PortBindings (Docker runtime) or to a socat forwarder (Cloud Hypervisor runtime); host_ip and protocol are honored by both runtimes. If published is already in use on the host, the start fails, and the conflict is surfaced as an error event on the deployment, not at ring apply time. Note that the same published port number is a valid, non-conflicting pair across two different host_ip values (e.g. 0.0.0.0:8080 and 127.0.0.1:8080) or across the two protocols (8080/tcp and 8080/udp), since both are distinct bindings to the kernel.

If you do not publish a port, the container is reachable only from inside its namespace. See how-to: isolate namespaces and route traffic.

network

Selects the container's network namespace.

network:
  mode: host
FieldTypeDefaultDescription
modeenumbridgebridge (default) or host.

bridge: the deployment is attached to a per-namespace bridge network (ring_{namespace}). This is the standard Docker behavior and matches what Ring did before this field existed.

host: the container shares the host's network namespace directly. It can bind to host ports without ports: mapping, sees real client IPs, and can use multicast / broadcast. Useful for L4/L7 reverse proxies (HAProxy, Nginx as edge), packet-capture sidecars, VPN gateways (WireGuard, Tailscale), and service-discovery agents (mDNS, Consul gossip).

When mode: host, the API rejects the deployment if:

  • ports: is non-empty, because host networking bypasses Docker's port bindings, so the mapping would be silently ignored. Remove ports: and let the container bind directly.
  • replicas: > 1, because all replicas would compete for the same host ports.
  • runtime: cloud-hypervisor, because host mode is Docker-only. The CH runtime has its own network model.

See how-to: use host network mode for the full walk-through.

labels

A free-form key/value map forwarded to Docker container labels. Useful for service discovery (Traefik), monitoring (Prometheus relabel rules), or filtering (docker ps --filter "label=key=value").

Cloud Hypervisor: the field is parsed but silently ignored, since there is no equivalent of Docker container labels in the VM model.

labels:
  app: api
  tier: backend
  version: v1.2.3
  "traefik.enable": "true"
  "traefik.http.routers.api.rule": "Host(`api.example.com`)"

Quote keys that contain dots, since YAML treats them as strings only when quoted.

In addition to user-supplied labels, Ring adds ring_deployment=<deployment-id> to every container. Do not remove this label: Ring uses it to discover the containers it owns.

resources

CPU and memory limits and requests:

resources:
  limits:
    cpu: "500m"        # 500 millicores = 0.5 CPU
    memory: "512Mi"
  requests:
    cpu: "100m"
    memory: "128Mi"
FieldDocker behaviorCloud Hypervisor behavior
limits.cpuSets HostConfig.NanoCpus (a hard cap, CPU is throttled when exceeded). Fractional values OK ("500m" = 0.5 core).Number of vCPU at boot (max(1, floor(cpu))). Fractional values are rounded down to whole vCPU, with a floor of 1. "500m" becomes 1 vCPU.
limits.memorySets HostConfig.Memory (a hard cap, overage triggers an OOM kill).Sets the VM's RAM size (max(128, bytes / 1MiB) MiB). Allocation, not a cap: the guest OS sees exactly this much RAM.
requests.cpuSilently ignored. The doc previously claimed it set Docker CPU shares; the code never sets cpu_shares.Silently ignored.
requests.memorySets HostConfig.MemoryReservation, a soft minimum (kernel tries to keep this much available, but doesn't enforce it strictly).Silently ignored.

Both limits and requests are optional. Within each, cpu and memory are also optional.

Memory admission control. Before creating a container or booting a VM, Ring checks the deployment's requested memory against the host's currently-available memory. The figure it admits against is requests.memory if set, otherwise limits.memory (and, on Cloud Hypervisor, the 256 MiB default when neither is set). If the host can't hold it, the deployment goes to the terminal status insufficient_resources with an event naming the gap (needs X MiB but only Y MiB is available), instead of starting and getting OOM-killed (Docker) or failing the VM spawn opaquely (CH). The check is best-effort and point-in-time (it catches gross over-asks, not fine-grained contention), and it is not applied to CPU, since CPU overcommit is non-fatal. A deployment that declares no memory request or limit is not gated.

Cloud Hypervisor sizing. When resources is not set on a CH deployment, the VM defaults to 1 vCPU and 256 MiB of RAM. Resizing is at-boot only; Ring does not use Cloud Hypervisor's vm.resize API to live-resize a running VM, so changing resources requires a redeploy.

Firecracker sizing. Reads limits (falling back to requests): the memory becomes the microVM's RAM and the CPU becomes its vCPU count, rounded up to whole vCPUs (Firecracker can't allocate fractional cores) with a floor of 1. When neither is set, a microVM defaults to 1 vCPU and 512 MiB, enough headroom to boot systemd plus a real service rather than OOMing at boot. Sizing is at-boot only; changing resources requires a redeploy.

CPU values

  • Millicores: "500m" (= 0.5 cores), "1500m" (= 1.5 cores)
  • Whole or fractional cores: "1", "0.5", "2"

Memory values

  • Raw bytes: "536870912"
  • IEC suffixes: "512Mi", "1Gi", "2Gi"

Cloud Hypervisor: resources.limits.cpu becomes the VM's vCPU count (minimum 1) and resources.limits.memory becomes the VM's RAM (minimum 128 MiB). requests is ignored on the CH runtime.

health_checks

A list of probe definitions. Each probe runs independently with its own counter and its own failure action. Three types: tcp, http, command.

health_checks:
  - type: http
    url: "http://localhost:8080/health"
    interval: "30s"
    timeout: "5s"
    threshold: 3
    on_failure: restart

  - type: tcp
    port: 5432
    interval: "10s"
    timeout: "2s"
    on_failure: alert

  - type: command
    command: "pg_isready -U postgres"
    interval: "15s"
    timeout: "3s"
    on_failure: restart

Common fields

FieldRequiredDescription
typeyestcp, http, or command.
intervalyesCurrently advisory (see health checks (design) → the probe cycle). Only ms and s suffixes parse.
timeoutyesProbe timeout. Only ms and s suffixes parse.
thresholdno (default 3)Consecutive failures before on_failure triggers.
on_failureyesrestart (recreate the instance), stop (delete the deployment), or alert (log only).
readinessno (default false)When true, this check gates rolling updates and (for command on Docker) is translated into a native HEALTHCHECK. See health checks (design) → the readiness gate.
min_healthy_timeno (default 10s)Anti-flap window for the readiness gate: the check must be green for this long before the parent is drained. Per-check; the scheduler takes the maximum across readiness checks. Ignored when readiness: false. Same syntax as interval / timeout.

Type-specific fields

TypeFieldDescription
tcpportTCP port inside the container/VM. Probe succeeds if the kernel accepts the SYN within timeout.
httpurlFull URL. localhost is rewritten to the instance's runtime-private IP. Probe succeeds on a 2xx response within timeout. Redirects (3xx) are not followed and count as failures.
commandcommandShell-tokenized command run inside the container via docker exec. Current behavior: the probe succeeds as soon as docker exec starts the command without an API error; the command's actual exit code is not checked. So a command that runs but exits non-zero will report success. This is a known limitation; track the code source for the fix.

Cloud Hypervisor caveat: tcp and http are supported (probes run from the host against the VM's deterministic guest IP). command is supported via the in-guest ring-agent daemon. See Cloud Hypervisor → Health checks.

See how-to: configure health checks for tuning and recipes, and health checks (design) for the rolling-update interaction.

config

Runtime-level configuration: image pull policy, registry credentials.

Container runtimes only. Every field of config is consumed by the container runtimes (Docker, Podman, containerd). On Cloud Hypervisor, the entire block is silently ignored: there is no image to pull, and the disk image at image: is read from the local filesystem.

config:
  image_pull_policy: "Always"        # "Always" or "IfNotPresent"
  server: "registry.company.com"     # private-registry hostname
  username: "registry-user"
  password: "$REGISTRY_PASSWORD"     # interpolated from shell env
FieldDescription
image_pull_policyAlways (default) or IfNotPresent. The third historical value Never skips the pull entirely.
serverPrivate-registry hostname (only for non-Docker-Hub registries).
username, passwordRegistry credentials. Sent to the runtime on pull.
use_host_authtrue to resolve registry credentials from the host's Docker config instead of inlining them. Mutually exclusive with server/username/password. See below.
image_pull_secretName of a Secret (same namespace) holding registry credentials as a dockerconfigjson payload. Resolved and decrypted server-side before the pull. Mutually exclusive with inline credentials and use_host_auth. See below.
user.idNumeric UID the container runs as (forwarded to User in Docker config). Optional.
user.groupNumeric GID. Optional.
user.privilegedBoolean. If true, the container is started with HostConfig.Privileged = true. Default false.

The password field is not an encrypted secret; it lives in the deployment row in the database. To avoid committing credentials, interpolate from the shell with $VAR and pass them via ring apply --env-file, or use use_host_auth to keep the secret on the host entirely.

use_host_auth: credentials from the host

Instead of inlining server/username/password, a deployment can pull using the credentials the operator already configured on the host (e.g. via docker login). The secret then never reaches the deployment manifest, the database, or the API.

config:
  image_pull_policy: "Always"
  use_host_auth: true        # read ~/.docker/config.json on the host

It is a two-flag handshake:

  1. The server must authorize it by setting use_host_registry_auth = true under the runtime's [server.runtime.*] section (see config-toml).
  2. The deployment activates it with use_host_auth: true.

If a deployment sets use_host_auth on a runtime that did not authorize it, the deployment fails fast with an actionable error (no silent fallback to an anonymous pull). Combining use_host_auth with inline server/username/password is rejected at apply time.

Supported on Docker, Podman and containerd. The host config follows the standard Docker resolution ($DOCKER_CONFIG, then ~/.docker/config.json), honoring credHelpers/credsStore. When the Ring daemon runs as a different user than the one who logged in (or for a Podman containers/auth.json), pin the file explicitly with host_registry_config in the server config.

image_pull_secret: credentials from an encrypted Secret

The portable, declarative alternative to use_host_auth: store the registry credentials in an encrypted Secret (AES-256-GCM at rest) and reference it by name. The credentials never appear in the manifest, the database row, or the API response; only the secret's name does.

The Secret's value is a Docker config.json payload (dockerconfigjson), the exact format docker login writes. The simplest way to create it is to log in once and store the resulting file:

docker login rg.fr-par.scw.cloud
ring secret create scaleway-registry -n production \
  --value "$(cat ~/.docker/config.json)"
# deployment: references the Secret by name, no credentials inline
config:
  image_pull_policy: "Always"
  image_pull_secret: "scaleway-registry"

Credential helpers. This works when docker login stores the credential inline in config.json (an "auth": "..." entry, the common case on Linux servers). If Docker is configured with a credential helper (credsStore/credHelpers, e.g. Docker Desktop), the file holds no usable credential, because it's in the OS keychain instead. In that case either write the auths entry by hand, or use use_host_auth, which resolves helpers on the host.

At deploy time the scheduler decrypts the Secret, picks the auths entry matching the image's registry host, and pulls with it. If the Secret is missing or has no entry for the registry, the deployment fails fast with an image_pull_secret_resolution_error event (the value is never logged). It must reference a Secret in the same namespace as the deployment, and is mutually exclusive with inline server/username/password and with use_host_auth.

Supported on Docker, Podman and containerd.

Full example

namespaces:
  production:
    name: production

deployments:
  api:
    name: api
    namespace: production
    runtime: docker
    kind: worker
    image: "registry.company.com/myapp:v1.2.3"
    replicas: 3

    command:
      - "node"
      - "server.js"

    environment:
      NODE_ENV: "production"
      PORT: "8080"
      LOG_LEVEL: "info"
      DATABASE_URL:
        secretRef: "database-url"
      JWT_SECRET:
        secretRef: "jwt-secret"

    volumes:
      - type: bind
        source: /var/log/api
        destination: /var/log/app
        driver: local
        permission: rw

      - type: config
        source: api-config
        destination: /etc/app/config.json
        driver: local
        permission: ro

    ports:
      - { published: 8080, target: 8080 }

    labels:
      app: api
      tier: backend
      version: "v1.2.3"
      "traefik.enable": "true"
      "traefik.http.routers.api.rule": "Host(`api.example.com`)"

    resources:
      limits:
        cpu: "1"
        memory: "1Gi"
      requests:
        cpu: "200m"
        memory: "256Mi"

    health_checks:
      - type: http
        url: "http://localhost:8080/health"
        interval: "10s"
        timeout: "5s"
        threshold: 3
        on_failure: restart

    config:
      image_pull_policy: "Always"
      server: "registry.company.com"
      username: "registry-user"
      password: "$REGISTRY_PASSWORD"

JSON form

The same shape, sent directly to the API:

{
  "name": "api",
  "namespace": "production",
  "runtime": "docker",
  "kind": "worker",
  "image": "registry.company.com/myapp:v1.2.3",
  "replicas": 3,
  "command": ["node", "server.js"],
  "environment": {
    "NODE_ENV": "production",
    "DATABASE_URL": { "secretRef": "database-url" }
  },
  "volumes": [
    {
      "type": "bind",
      "source": "/var/log/api",
      "destination": "/var/log/app",
      "driver": "local",
      "permission": "rw"
    }
  ],
  "ports": [
    { "published": 8080, "target": 8080 }
  ],
  "labels": { "app": "api" },
  "resources": {
    "limits": { "cpu": "1", "memory": "1Gi" },
    "requests": { "cpu": "200m", "memory": "256Mi" }
  },
  "health_checks": [
    {
      "type": "http",
      "url": "http://localhost:8080/health",
      "interval": "10s",
      "timeout": "5s",
      "threshold": 3,
      "on_failure": "restart"
    }
  ],
  "config": {
    "image_pull_policy": "Always"
  }
}

See also