Skip to main content

Deploy apps

RunOS builds and runs your app in the cluster. You bring a Dockerfile and a runos.yaml; RunOS does the build, the image push, the rollout, the route, and the certificate.

Start with the way you deploy. Everything else (the manifest, config, dependencies) is the same across all of them.

Ways to deploy

There are six. They all end with a running pod behind an HTTPS route. Pick by where your code lives and who pulls the trigger.

  1. CLI local-directory deploy. runos deploy reads runos.yaml from the current directory, tarballs the project, and RunOS builds it in-cluster. No local Docker, no git, no registry login. Fastest path from a laptop. Use for prototypes and apps without a CI pipeline.

  2. VCS deploy. runos deploy --sha <sha> --app <id> builds the exact tree at a commit SHA through a GitHub or GitLab integration. Content-addressed: the same SHA plus the same build args produces a byte-identical image, reused from Harbor on the next deploy. Use for anything with a git history and reproducible builds.

  3. One-off task. runos run builds the SHA image (if needed) then runs a single command, typically a DB migration, seed, or backfill, in the app's namespace with the app's env and secrets injected. The CLI blocks and exits with the container's real exit code. VCS apps only: run rebuilds from a SHA, and a tarball app has no SHA to rebuild from. Sibling to deploy: deploy is build-then-rollout, run is build-then-execute.

  4. CI pipelines. runos apps ci-file prints a ready pipeline (GitHub Actions or GitLab CI) where the runner installs runos and calls runos deploy --sha; the RunOS API and cluster agent do the build, push, and rollout. runos apps ci-file-manual is the legacy shape (the runner builds with buildctl and patches the deployment with kubectl, no runos CLI). Works with GitHub Actions, GitLab CI, and github-arc self-hosted runners. Both are VCS-only.

  5. AI assistants over MCP. Point Claude Code, Codex, Gemini CLI, or OpenCode at the RunOS MCP server and deploy from a prompt. Run runos mcp configure <claude|codex|gemini|opencode> once; the assistant must call runos mcp bootstrap as its first tool. See Deploy from an AI assistant.

  6. Console. The web UI at the Console URL. Point, click, fill the form. Use when you want a GUI instead of a terminal.

The runos.yaml manifest

One file describes the whole app. runos deploy reads it from the current directory by default (-c <path> to point elsewhere). A minimal one:

app: My API
servicePortMappings:
- port: 8080
standardHttps: true

The keys you will actually use:

  • app (required): display name.
  • id: the app's 5-character id, e.g. k4n9p. Leave it out on first create; RunOS writes it back into the file so later deploys target the same app. The app's OSID and Kubernetes namespace are app-<id>.
  • cid: cluster id. Resolves from the --cid flag, then your CLI default, then this field.
  • resourceRequirementClassId: sizing class, grammar service.tier.size, e.g. app.sl1.beff (see Core concepts for tiers and sizes). Overriding cpuLimitMc / memoryLimitMb / replicas flips the class to custom. Default sizing is best-effort (limits only, zero requests), so pods run Burstable. There is no autoscaling.
  • servicePortMappings: a list of {port, standardHttps?, domains?}. port is the container port your app listens on (1-65535); use one above 1024, since the container runs non-root and can't bind privileged ports. standardHttps: true (or omitted) publishes the port on 443 (public). standardHttps: false routes it to a VPN-only internal entrypoint instead of the public one. Each domains entry is {fqdn, enableCloudflareProxy?} and attaches that FQDN to this specific port.
  • env: path to a plain env file (ConfigMap-backed, committed to VCS). secretEnv: path to a sensitive env file (Secret-backed, gitignored). The same key in both files is a hard error (the CLI checks before deploy, the RunOS API enforces it server-side).
  • requires: service dependencies. See Service dependencies.
  • healthCheck: preset, one of none, aggressive, standard, relaxed. Omit to use the server default. With healthCheckPath and healthCheckPort for the probe target.
  • deploymentStrategy: rollout preset, one of rolling (default), zero-downtime, resource-constrained, recreate, fast. Pick recreate when the next pod can't schedule until the old one frees its resource (single GPU, RWO volume, exclusive port).
  • nodeAffinityTags: pin pods to nodes carrying these tags (key or key:value). Tags must already exist (runos tags list).
  • dockerfile: path to the Dockerfile, relative to the source dir (default Dockerfile).
  • buildArgs: a map of Docker build args (see Config).
  • sourceDir and configPath: for monorepos. sourceDir is the build context relative to the yaml's directory (use .. when the yaml lives in a per-app subfolder). configPath is the repo-relative path of the yaml itself; VCS deploys auto-derive it, so you rarely set it.

The decoder is strict: an unknown key (a typo like healtCheck or replica) fails the deploy and names the bad field. Omitting a field that was set before clears it on the next deploy. The manifest is desired state.

The Dockerfile contract

RunOS runs your image as a locked-down, non-root container. The build is yours. Meet a few rules and it runs as a locked-down, non-root pod.

  • Run as non-root, UID 10001. Create the user and USER to it before CMD.
  • Make the app readable by that user. chmod -R a+rX (or chown -R) the app directory.
  • EXPOSE a port above 1024 that matches a servicePortMappings entry. Low ports won't bind as non-root.
  • Keep the build reproducible. Generate lockfiles inside the build, don't depend on the host.

A Go service, abridged:

FROM alpine:latest
RUN addgroup -g 10001 -S app && adduser -S app -u 10001 -G app
WORKDIR /app
COPY --from=builder /app/main .
RUN chown -R app:app /app
USER app
EXPOSE 8080
CMD ["./main"]

For a static site, base on nginxinc/nginx-unprivileged and serve on 8080; it already runs as non-root and binds a high port. A bare nginx: base binds port 80 and needs root, so it won't start here. Use the unprivileged image instead.

Config: env vars, build args, build-time auth

Three layers, do not conflate them. They enter the app at different times and live in different places.

  • Build args are Dockerfile ARG values, set with buildArgs: in the manifest or --build-arg KEY=VALUE on the CLI (--build-arg wins on conflicts, merged server-side). Build-time only. Fine for a version pin, never for a secret (they end up in image layers).
  • Runtime env vars are what the running process reads. Plain vars go in the env file (ConfigMap-backed, committed). Sensitive vars go in the secretEnv file (Secret-backed, gitignored). Inspect with runos apps env-vars and runos apps secret-env-keys.
  • Build-time auth is for credentials the build needs but the image must never keep: a deploy key for a private git dependency, a registry token for a private package. Set them with runos apps set-build-ssh-key <id> (BuildKit --mount=type=ssh) and runos apps set-build-secret <id> --secret-id <name> (BuildKit --mount=type=secret,id=<name>). They are write-only and never land in the image, the registry cache, or the running app. Reference them in the Dockerfile with RUN --mount=type=ssh ... / RUN --mount=type=secret,id=<name> ....

Service dependencies (requires)

requires wires a managed service into your app at deploy time. Key each entry by an alias:

requires:
db:
id: postgresql-bcv4l
type: postgresql
cache:
type: valkey
class: valkey.c0.beff

RunOS auto-wires postgresql, mysql, and valkey: on first deploy it provisions a missing database and user, then injects connection env vars for that alias (fields url, host, port, database, username, password). Other service types (e.g. minio) are not auto-wired; you connect to them by hand with their own credentials.

Link a dependency by id (an existing service OSID) or by class (a resource class, which provisions a new instance). The class shorthand works on CLI-deploy apps only. Inspect what an app depends on with runos apps dependencies or runos apps requires.

deploy, build, and run

Three verbs split the build pipeline into single-purpose steps:

VerbDoesApps
runos deploybuild, then roll outCLI and VCS
runos apps buildbuild and push the image, no rolloutVCS only
runos runbuild, then run one commandVCS only

All three follow the same job convention. By default the CLI prints the jobId and exits 0 the moment the RunOS API accepts the request. Pass -f / --follow to block, stream progress, and exit on the job's real result (non-zero on failure). Accepting a deploy is not the same as it converging, so gate any script on --follow.

# CLI deploy from the project directory
runos deploy --cid ky3 --follow

# VCS deploy at a commit
runos deploy --app k4n9p --sha "$(git rev-parse HEAD)" --cid ky3 --follow

# Build the image without rolling out
runos apps build --app k4n9p --sha "$GITHUB_SHA" --cid ky3 --follow

# One-off migration against the SHA image
runos run --app k4n9p --sha "$GITHUB_SHA" --cid ky3 -- alembic upgrade head

Custom domains and SSL

Every app gets a built-in route automatically, an HTTPS URL under the cluster's wildcard domain. Nothing to configure. See it with runos apps network-access <id>.

For your own hostname, add a custom domain:

runos domains add --cid ky3 --zone example.com --subdomain api \
--target-namespace app-k4n9p --target-service app-k4n9p --target-port 8080

Point a CNAME at the built-in route. Let's Encrypt issues and renews the certificate: HTTP-01 by default, or Cloudflare DNS-01 when a Cloudflare DNS integration covers the zone. You can also declare the FQDN inline under servicePortMappings[].domains, where enableCloudflareProxy: true routes traffic through Cloudflare's edge (needs that same Cloudflare integration). List everything exposed with runos domains list and runos domains ingresses.

CI/CD from a runner

Generate the pipeline, don't hand-write it: runos apps ci-file <id> prints a GitHub Actions or GitLab CI file (driven by the app's integration). The runner installs runos and calls runos deploy --sha. For the legacy buildctl + kubectl shape use runos apps ci-file-manual. Both are VCS-only and return a 400 on CLI-deploy apps.

Two values authenticate the runner:

  • RUNOS_API_KEY: a PAT (runos_pat_<keyId>.<secret>). Store it as a secret. A set-but-empty value is a hard error.
  • RUNOS_ACCOUNT_ID: your account id (aid). A plain variable.

Two rules keep CI honest:

  • Gate on --follow. A bare deploy exits 0 on acceptance, not on success. CI that doesn't follow will go green on a build that later fails.
  • Sync services before apps. Provision dependencies first so the app's requires resolves on the first deploy.

Deploy from an AI assistant (MCP)

RunOS ships an MCP server that drives the platform. Configure it once for your assistant:

runos mcp configure claude   # or codex | gemini | opencode

This writes .mcp.json and, for Claude Code, .claude/settings.json. The assistant's required first call is runos mcp bootstrap, which returns the rules for using RunOS plus an index of help topics; without it the other tools are off-limits. Tools are split into four permission tiers: read, sensitive-read, write, and sensitive-write, so you can let an agent inspect freely while gating mutations. Browse topics with runos mcp topics.

This is the platform MCP server. It is what lets an assistant run a deploy, read logs, or wire a dependency on your behalf.

Operating a deployed app

Apps hold the replica count you set, so day-two is a handful of commands:

runos apps status <id> --cid ky3     # rollout state
runos apps logs <id> --cid ky3 --follow # tail container logs
runos apps restart <id> --cid ky3 # rolling restart (e.g. after a ConfigMap change)
runos apps show <id> --cid ky3 # full app detail

Update config by editing runos.yaml and running runos deploy again, or with runos apps update. A name-only change is a cheap sync; any other field triggers an async redeploy.