CLI

suble.yml reference

One declarative file describes a whole instance. suble deploy / suble update reconcile it idempotently. Every field is below.

suble.yml
name: web                      # instance hostname (lowercase, digits, hyphens)
plan: BXS.s1                   # plan code — see the dashboard catalog

# --- source: pick exactly one -------------------------------------------------
image: ghcr.io/me/app:1.4.2    # build: | image: | app: | os: | template:

# --- container ----------------------------------------------------------------
env:
  NODE_ENV: production
  DATABASE_URL: ${DATABASE_URL}         # ${VAR} expands from your shell/CI at deploy
  STRIPE_KEY: "%{project.stripeKey}%"   # %{project.NAME}% = a shared Suble variable
volumes:
  - "data:/var/lib/app"           # named volumes persist across redeploys
resources:
  cpus: "0.5"                     # CPU share (e.g. 0.5, 2)
  memory: 512m                    # memory limit (e.g. 512m, 1g)
expose:
  domain: app.example.com         # ingress + automatic Let's Encrypt TLS
  port: 8080

# --- deploy: how re-deploys roll out -----------------------------------------
deploy:
  strategy: rolling               # rolling (default) | canary | blue-green | immediate
  replicas: 3                     # load-balanced copies (requires expose:)
  max_unavailable: 1              # replicas cycled per batch (rolling/canary) — 1 or "25%"
  drain: 10                       # seconds to let an old container finish before it's stopped
  keep: 3                         # image versions kept on the VM for `suble rollback` (older pruned)
  healthcheck:
    type: http                    # docker (default) | http | tcp
    path: /healthz
    port: 8080                    # inferred from expose:/ports: if omitted
    status: 200
    initial_delay: 5              # wait before the first probe (slow boot)
    interval: 3                   # seconds between probes
    retries: 10                   # failed probes tolerated before aborting
    timeout: 60                   # overall deadline to become healthy

firewall: smart                   # off | smart | strict
networks: ["my-net"]
backup: basic                     # none | basic | extended

Instance

Top-level fields that identify the instance. name and plan are required; everything else is optional.

Fields

namerequiredstringInstance hostname — lowercase letters, digits and hyphens. Used to find-or-create the instance (it's never recreated once it exists).
planrequiredstringPlan code from the dashboard catalog (e.g. BXS.s1). Plan changes are warned about, not auto-resized.
projectstringProject uid or name to deploy into. Defaults to your only project, or prompts when interactive.
passwordstringOS root login. Use ${ENV_VAR} — never a literal. Only applied on first create.
backupnone | basic | extendedAutomatic backup tier for the instance.

Source — pick exactly one

There's no kind: field — the source key you set picks the type. build/image run a Docker container; app is a managed 1-click app or database; os/template provision a VM.

Fields

buildblockBuild a local Dockerfile and push it to your private registry.suble.io; the instance pulls it automatically (no docker login on the server). Sub-fields: dockerfile (default Dockerfile), context (default .), tag, container.
imagestringRun a published registry image, e.g. nginx:1.27 or ghcr.io/you/app:1.4.2. Pin a version for reproducible deploys. An image in your own registry.suble.io/<project>/… namespace is pulled with automatic, pull-only auth — no docker login needed (see the Container Registry docs).
appstringA managed 1-click app or database: postgresql, mysql, mariadb, mongodb, redis, clickhouse, coolify, docker, …
osstringA plain OS image (e.g. ubuntu-24.04).
templatestringProvision from one of your saved templates (tpl_…).
build: sub-fields
build:
  dockerfile: Dockerfile   # default
  context: .               # default
  tag: web:latest
  container: web

Container

Applies to build:/image: sources. Ports, environment and volumes for the container.

Fields

portsstring[]Host:container port mappings, e.g. ["80:8080"]. Not used with deploy.replicas > 1 (they'd collide — use expose:).
envmap | string[]Environment variables, as a mapping (KEY: value) or a list of "KEY=VALUE". Two interpolations: ${VAR} expands from your local/CI environment; %{project.NAME}% / %{cluster.NAME}% pull a shared Suble variable, resolved server-side at deploy so no secret is committed.
volumesstring[]Volume mounts host:container or name:container, e.g. ["data:/var/lib/app"]. Named volumes persist across redeploys.

Shared variables

Reference a project- or cluster-scoped shared variable inside any env value. Unlike ${VAR} (expanded from your local/CI environment by the CLI at deploy), these are resolved server-side — so the secret lives in Suble, never in your committed suble.yml. A missing reference fails the deploy loudly. Create them under Project → Settings → Shared variables, or on a cluster's page.

Fields

%{project.NAME}%referenceA project variable — usable by every instance and cluster in the project.
%{cluster.NAME}%referenceA cluster variable — only that cluster's services can reference it.
suble.yml
env:
  NODE_ENV: production
  # a project secret, resolved server-side at deploy
  STRIPE_KEY: "%{project.stripeKey}%"
  # embed a cluster secret inside a larger value
  DATABASE_URL: "postgres://app:%{cluster.dbPassword}%@db:5432/app"

resources — CPU & memory limits

Cap what the container can consume so one container (or replica) can't starve the others on the instance. Applies to build:/image: sources.

Fields

resources.cpusnumberCPU share — fractional cores, e.g. "0.5" or "2" (Docker --cpus).
resources.memorysizeHard memory limit, e.g. "512m" or "1g" (Docker --memory).

expose — ingress + automatic SSL

Serve the container at a hostname behind the managed Caddy reverse proxy, with a Let's Encrypt certificate issued automatically once DNS points at the instance. Required for deploy.replicas > 1 and for zero-downtime blue/green.

Fields

expose.domainrequiredstringFull domain, e.g. app.example.com. Point its A record at the instance for the cert to issue.
expose.portnumberContainer port Caddy proxies to (default 80).

loadbalancer — front a single instance with a managed LB

Put a dedicated managed load balancer (its own Caddy/HAProxy VM, or an HA pair with a floating VIP) in front of this instance, instead of the in-VM ingress — for a stable ingress IP, HA at the edge, or terminating TLS off the app VM. Requires expose: (the listener + backend target are derived from it). Point the domain's DNS at the LB IP printed after deploy. Declarative + idempotent — the LB is reused across deploys by name. (For load-balancing across many VMs, use a cluster instead.)

Fields

loadbalancer.sizelb.xs | lb.s | lb.m | lb.lLB VM size. Default lb.xs.
loadbalancer.habooleanProvision an HA pair with a floating VIP instead of a single LB VM. Default false.
loadbalancer.protocolhttp | https | tcpListener protocol. https (default) ⇒ automatic TLS on expose.domain (so https needs expose.domain).
loadbalancer.networkstringPrivate network (name/uid) to reach the backend over, instead of its public IP.
loadbalancer.namestringLB name; defaults to <instance>-lb. Reused across deploys to stay idempotent.
suble.yml — instance + managed LB
# A single instance fronted by a MANAGED load balancer (dedicated Caddy/HAProxy VM, or an
# HA pair) instead of the in-VM ingress. The listener + backend are derived from expose:.
name: web
plan: BXS.s1
image: ghcr.io/acme/web:1.4.0
expose:
  domain: app.example.com    # the LB serves this host
  port: 8080
loadbalancer:
  size: lb.xs                # lb.xs | lb.s | lb.m | lb.l
  ha: false                  # true → HA pair with a floating VIP
  protocol: https            # https ⇒ auto-TLS on expose.domain
deploy:
  strategy: rolling
  replicas: 2

deploy — strategy, replicas & health checks

How re-deploys roll out. Omit it for the default (rolling). All strategies except immediate health-check the new container(s) before sending them traffic; replicas runs several load-balanced copies. Applies to build:/image: containers.

Fields

deploy.strategyrolling | canary | blue-green | immediaterolling (default): replace replicas in batches of max_unavailable, surging new before dropping old — zero-downtime. canary: verify ONE new container first, then roll the rest. blue-green: bring up the full new set, health-check all, switch traffic atomically (instant rollback; 2× resources). immediate: replace all at once with no health checks (brief downtime). With 1 replica behind expose:, the gated strategies all do a zero-downtime swap.
deploy.replicasnumberNumber of load-balanced copies to run (default 1, max 20). Requires expose: and no host ports:. The ingress round-robins across them; the strategy rolls the whole set.
deploy.max_unavailableint | "N%"Replicas cycled per batch for rolling/canary (default 1). E.g. 1 or "25%". Larger = faster rollout, more mixed-version overlap.
deploy.drainnumberSeconds an old container gets to finish in-flight requests (SIGTERM) before it's stopped during cut-over. Default 10; 0 to kill immediately. Ignored by immediate.
deploy.keepnumberHow many image versions to keep on the VM after a successful deploy (default 3, 1–50); older ones are pruned to reclaim disk. These kept versions are what `suble rollback` reverts to — the previous one is already on the instance, so rollback needs no rebuild or pull. build: sources are tagged per revision so each deploy is a distinct, rollback-able image.
deploy.healthcheck.typedocker | http | tcpdocker (default): container running + stable + image HEALTHCHECK if present. http: GET the path and expect status. tcp: port accepts a connection. http/tcp probe the container's network IP, so no published port is needed.
deploy.healthcheck.pathstringHTTP path to probe (http only), e.g. /healthz. Default /.
deploy.healthcheck.portnumberContainer port to probe (http/tcp). Inferred from expose:/ports: when omitted.
deploy.healthcheck.statusnumberExpected HTTP status (http only). Default 200.
deploy.healthcheck.timeoutnumberOverall seconds to become healthy before the deploy aborts. Default 60.
deploy.healthcheck.intervalnumberSeconds between probes. Default 3.
deploy.healthcheck.initial_delaynumberSeconds to wait before the FIRST probe — give a slow-booting app time to start listening. Default 0.
deploy.healthcheck.retriesnumberFailed probes tolerated before the deploy aborts. Default ≈ timeout ÷ interval. Whichever of retries or timeout hits first stops the deploy.
canary then rolling across 4 replicas
deploy:
  strategy: canary       # verify 1 new container, then roll the rest
  replicas: 4            # 4 copies, load-balanced by the ingress
  max_unavailable: 2     # after the canary, replace 2 at a time
  healthcheck:
    type: http
    path: /healthz
    status: 200
    timeout: 60

firewall

A string shorthand (off | smart | strict) or a block. smart keeps management ports reachable; strict is default-deny and requires an allow: list of source CIDRs so a headless run can't lock you out. Manual rules: are only valid with mode: off.

Fields

firewall.moderequiredoff | smart | strictoff: default-allow (+ optional rules:). smart: sensible managed defaults. strict: default-deny (+ required allow:).
firewall.allowstring[]Source CIDRs allowed under strict (required for strict).
firewall.rulesrule[]Explicit rules (mode: off only): direction (in|out), action (ACCEPT|DROP|REJECT), and optional proto/source/dest/sport/dport/comment.

networks

Private networks to attach the instance to, by name or uid. Attach-if-missing — never auto-detached.

Fields

networksstring[]Private network names or uids, e.g. ["my-private-net"].

cluster — a container cluster (infra)

A cluster is shared infrastructure — N member VMs + a managed load balancer + a private network — that runs one or more container services (see service: below). A cluster: block defines that infrastructure; deploy it with suble deploy, or create it imperatively with suble cluster create. To run a workload, deploy a service into it.

Fields

cluster.sizerequiredstringMember VM plan code (e.g. BXS.s1). Every member runs this plan.
cluster.replicasnumberNumber of member VMs (default 2, max 9). This is the VM count — a service's own replicas set how many container copies run across them.
cluster.habooleanProvision an HA load-balancer pair with a floating VIP instead of a single LB. Default false.
cluster.networkstringPrivate network (name or uid) the members + LB share. Auto-created if omitted.
cluster.image / cluster.port / cluster.domainshorthandOptional single-service shorthand: set an image (+ port, optional domain) to seed one "web" service at create time, instead of adding services separately.
cluster.enginecoming soonDatabase clusters (postgres/mysql/mongodb/redis) are not available yet — app/container clusters only for now.
suble.yml — cluster infrastructure
# suble.yml — a cluster's INFRASTRUCTURE: member VMs + a load balancer +
# a private network. Services (below) are deployed into it separately.
name: shop
cluster:
  size: BXS.s1        # member VM plan
  replicas: 3         # number of member VMs
  ha: true            # HA load-balancer pair (floating VIP) instead of a single LB
  # network: shop-net # optional existing private network (auto-created if omitted)

service — deploy one service into a cluster

The per-repo model: each repo has its own suble.yml with a service: block naming the cluster it belongs to. suble deploy builds + pushes (if build:) and create-or-updates ONLY this service — it never touches the cluster's other services, so many repos with their own CI/CD can share one cluster. If the target cluster doesn't exist, the optional cluster* fields bootstrap it (and are ignored if it already does).

Fields

service.clusterrequiredstringTarget cluster (name or uid). Must exist, or be bootstrapped via the cluster* fields below.
service.build | service.imagerequiredblock | stringSource: build a local Dockerfile (pushed to your registry.suble.io namespace, pulled automatically), or run a prebuilt image. Exactly one.
service.portrequirednumberContainer port the service listens on.
service.replicasnumberContainer copies, spread round-robin across the cluster's members (default 1, max 9).
service.domainstringPublic hostname → exposed via the cluster LB with https + auto-TLS (point its DNS at the LB). Omit to serve on the LB's IP over http (one no-domain service per cluster), or leave internal.
service.envmap | string[]Environment variables (KEY: value or "KEY=VALUE"). ${VAR} expands from the environment; %{project.NAME}% / %{cluster.NAME}% reference a shared Suble variable, resolved at deploy. Merged over the cluster's env.
service.cpus / service.memorynumber / sizePer-container limits, e.g. cpus: "1", memory: 512m.
service.clusterSize / clusterInstances / clusterHa / clusterNetworkbootstrapUsed ONLY to create the cluster if it's missing (member plan / count / HA / network). Ignored when the cluster already exists — so repos never fight over the member count.
suble.yml — a service (repo: web)
# suble.yml in a repo — deploys THIS repo as ONE service into a shared cluster.
# Each repo has its own suble.yml + CI/CD; they all share the same cluster.
name: web
service:
  cluster: shop               # target cluster (bootstrapped if missing — see cluster* below)
  build:                      # build this repo's Dockerfile + push to your registry…
    dockerfile: Dockerfile
  # image: registry.suble.io/acme/web:1.4.0   # …or run a prebuilt image instead
  port: 3000                  # container port
  replicas: 3                 # copies, spread across the cluster's members
  domain: app.example.com     # exposed via the cluster LB (https + auto-TLS)
                              #   omit → served on the LB's IP over http
  env:
    NODE_ENV: production
  cpus: "1"                   # optional per-container limits
  memory: 512m

  # --- optional: create the cluster if it doesn't exist yet (IGNORED if it does,
  #     so two repos never fight over the member count) ---
  clusterSize: BXS.s1
  clusterInstances: 3
  clusterHa: true

Examples & CI/CD

Copy-paste starting points. The single-instance and cluster/service files are all just suble.yml — suble deploy (interactive) or suble update (CI, with a project API key) reconciles whichever one is in the repo.

Single container instance
name: web                      # instance hostname (lowercase, digits, hyphens)
plan: BXS.s1                   # plan code — see the dashboard catalog

# --- source: pick exactly one -------------------------------------------------
image: ghcr.io/me/app:1.4.2    # build: | image: | app: | os: | template:

# --- container ----------------------------------------------------------------
env:
  NODE_ENV: production
  DATABASE_URL: ${DATABASE_URL}         # ${VAR} expands from your shell/CI at deploy
  STRIPE_KEY: "%{project.stripeKey}%"   # %{project.NAME}% = a shared Suble variable
volumes:
  - "data:/var/lib/app"           # named volumes persist across redeploys
resources:
  cpus: "0.5"                     # CPU share (e.g. 0.5, 2)
  memory: 512m                    # memory limit (e.g. 512m, 1g)
expose:
  domain: app.example.com         # ingress + automatic Let's Encrypt TLS
  port: 8080

# --- deploy: how re-deploys roll out -----------------------------------------
deploy:
  strategy: rolling               # rolling (default) | canary | blue-green | immediate
  replicas: 3                     # load-balanced copies (requires expose:)
  max_unavailable: 1              # replicas cycled per batch (rolling/canary) — 1 or "25%"
  drain: 10                       # seconds to let an old container finish before it's stopped
  keep: 3                         # image versions kept on the VM for `suble rollback` (older pruned)
  healthcheck:
    type: http                    # docker (default) | http | tcp
    path: /healthz
    port: 8080                    # inferred from expose:/ports: if omitted
    status: 200
    initial_delay: 5              # wait before the first probe (slow boot)
    interval: 3                   # seconds between probes
    retries: 10                   # failed probes tolerated before aborting
    timeout: 60                   # overall deadline to become healthy

firewall: smart                   # off | smart | strict
networks: ["my-net"]
backup: basic                     # none | basic | extended
repo #2 → same cluster (api)
# a DIFFERENT repo — same cluster "shop", its own service, its own pipeline.
name: api
service:
  cluster: shop
  image: registry.suble.io/acme/api:2.1.0
  port: 8080
  replicas: 2
  domain: api.example.com
internal worker service (no domain)
# an internal worker — no domain, so it isn't exposed by the LB
# (still reachable from the other services inside the cluster).
name: worker
service:
  cluster: shop
  image: registry.suble.io/acme/worker:2.1.0
  port: 9000
  replicas: 2
raw-TCP service (protocol: tcp)
# a raw-TCP service — a non-HTTP protocol published straight through the LB.
# protocol: tcp + listenPort ⇒ HAProxy forwards raw TCP to your containers (no domain, no TLS
# termination — your app does its own TLS). For game servers, brokers, custom protocols, etc.
name: game
service:
  cluster: shop
  image: registry.suble.io/acme/game-server:1.0
  port: 25565           # the container's port
  replicas: 3
  protocol: tcp
  listenPort: 25565     # the external LB port clients connect to (not 80/443)
GitHub Actions — deploy on push
# .github/workflows/deploy.yml — deploy this repo's service on every push to main.
name: Deploy to Suble
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest   # runners have Docker, so `build:` works
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      # A project API key authenticates the CLI (no browser). `update` is non-interactive:
      # it builds + pushes (if build:) and reconciles the service into the cluster.
      - run: npx @suble/cli@latest update
        env:
          SUBLE_API_KEY: ${{ secrets.SUBLE_API_KEY }}
          SUBLE_PROJECT: ${{ vars.SUBLE_PROJECT }}   # or set project: in suble.yml
GitLab CI
# .gitlab-ci.yml
deploy:
  image: node:20
  services: [docker:dind]      # build runs against Docker-in-Docker
  variables:
    DOCKER_HOST: tcp://docker:2375
  script:
    - npx @suble/cli@latest update
  # SUBLE_API_KEY + SUBLE_PROJECT set as masked CI/CD variables
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
Imperative — no file needed
# No file needed — manage a cluster's services straight from the CLI:
suble cluster create shop --size BXS.s1 --replicas 3 --ha
suble cluster service add shop web --image nginx:1.27 --port 80 --replicas 3 --domain app.example.com
suble cluster service add shop api --image ghcr.io/acme/api:2.1 --port 8080 --replicas 2
suble cluster service scale shop api --replicas 4
suble cluster service deploy shop web --image nginx:1.29   # rolling, one at a time
suble cluster show shop
suble cluster service rm shop worker

Databases & users (managed DB apps)

For app: postgresql/mysql/… — databases and users to provision on first deploy. The engine's root credentials are generated server-side; fetch them with suble app info.

Fields

databasesstring[]Databases to create on first deploy.
usersuser[]DB users to create on first deploy: name, password (use ${VAR}), and optional database.