An app is a piece of software that ORC8R installs and runs on your nodes. Instead of logging into each machine and setting things up by hand, you attach an app to a pool and ORC8R installs it on every node for you. This page explains how to use existing apps and, for the more technical reader, the full artifact.yaml reference for authoring your own — including apps that serve traffic.
How apps get onto nodes
You choose apps when you request nodes. In the Choose apps step of the request wizard (see Pools):
- Click Add Application.
- Pick an app from the catalog.
- Choose a version, and fill in any settings the app asks for.
Every node created for that pool then installs the apps you selected. If you add none, nodes start with just the agent. Some apps run a task once and finish (for example, installing a package); others run continuously as a service.
Configuring apps for a project or organization
You can set defaults for an app so that everyone requesting nodes gets sensible values without having to fill them in each time. Open a project and go to Project Apps (or an organization and go to Organization Apps). For each app you can:
- Pin a version so new nodes always use a known-good release.
- Set default values for the app's settings.
- Lock settings so users cannot change them at request time.
- Disable the app so it does not appear in the catalog for that project or organization.
Settings flow from broad to narrow: an organization's defaults apply to all its projects, and a project can add its own on top. If your organization disables an app, a project inside it cannot turn it back on.
Authoring your own app
If the app you need does not exist, you can build one. An app is packaged as a standard OCI artifact (the same kind of package used for container images), so it can be stored in an ordinary registry. You describe the app in a small file called artifact.yaml, then build it with the orc build command, which validates the file and tells you about mistakes while packaging rather than at deploy time.
The sections below are the complete reference for that file.
The shape of artifact.yaml
The file has five top-level keys:
| Key | What it holds |
|---|---|
artifactType | Always application/vnd.orc8r.app.v1 — this is what marks the package as an ORC8R app. |
annotations | The app's name and description, as org.opencontainers.image.title and org.opencontainers.image.description. |
config | Everything about how the app behaves: its settings, its endpoints, and its lifecycle. The rest of this page is config. |
files | The scripts and binaries the package ships — a list of paths. They land in the app's working directory. |
platforms | The operating systems and architectures the app supports, each a {os, arch} pair — for example Linux on amd64 and arm64. |
Settings: the params block
config.params describes the settings an operator fills in. It has two parts: required, the list of setting names that must be provided, and properties, a map from each setting name to how it behaves.
| Property field | Meaning |
|---|---|
type | The value's type, usually string. |
title | The label shown for the field in the request form. |
description | Help text shown under the field. |
placeholder | Example text shown in the empty field. |
enum | A fixed list of choices, offered as a dropdown instead of a free-text box. |
contentMediaType | The value's media type, such as application/x-pem-file. It also makes the value arrive as a file rather than an environment variable (see Values the platform fills in). |
sensitive | true stores the value securely and never echoes it back in plaintext. |
lifetime | startup (withdrawn once the app has started) or runtime (available for the whole run). When unset, operator secrets default to startup and everything else to runtime. |
x-source | Marks a value the platform fills in on the node rather than a person typing it — see Values the platform fills in. |
Each setting reaches your commands as an environment variable with an upper-cased name — a setting called packages arrives as PACKAGES.
This is the built-in apt app, which installs Debian or Ubuntu packages. It has one required setting and runs a script during the install phase:
artifactType: application/vnd.orc8r.app.v1
annotations:
org.opencontainers.image.title: apt
org.opencontainers.image.description: Install Debian/Ubuntu packages via apt-get
config:
params:
type: object
required: [packages]
properties:
packages:
type: string
title: Packages
description: Space-separated package names (e.g. git curl htop)
placeholder: "curl git vim"
install:
command: sh install-apt.sh
files:
- install-apt.sh
platforms:
- os: linux
arch: amd64
- os: linux
arch: arm64
When someone adds this app to a pool and types curl git vim into the Packages field, every node runs install-apt.sh with PACKAGES set to curl git vim, installing those packages.
Lifecycle phases
The rest of config is the app's lifecycle, given as named phases. You include only the ones you need. A phase's command is either a string (sh start.sh) or an argv list (["/usr/sbin/server", "--flag"]).
| Phase | Runs | Fields |
|---|---|---|
install | once, when the app is first set up on a node | command, timeout |
start | to launch the app | command, service, restart, gui |
drain | before a node is taken out of service, to let the app quiesce | command, timeout |
stop | to stop a running service | signal, timeout |
finish | after the app's work is done | command, timeout |
uninstall | when the app is removed from the node | command, timeout |
The start phase is what makes the difference between the two kinds of app. A start command that keeps running is the app's long-lived service; an app with only an install command that finishes is a one-time task. Its other fields cover apps run as a managed system service or as a desktop program: service and restart for the former, gui for the latter. The stop phase names the signal to send and how long to wait before the app is killed.
Declaring endpoints
An app that serves traffic says so by declaring endpoints. An endpoint is one thing your app listens for connections on: where it listens, what it speaks there, how to tell when it is ready, and whether more than one node may answer for it. That is the whole contract a package carries. Nothing about who may reach it or which network it sits on belongs in a package — those are decisions of the deployment, made in the request composer (see Project and organization exposure). Put the other way round: your package says what you serve, and the deployment says who may reach it.
endpoints is a map under config, keyed by endpoint name:
config:
endpoints:
pg:
port: 5432
metrics:
port: 9187
protocol: http
probe:
http: /metrics
| Field | Meaning |
|---|---|
port | Required. The listen port, 1–65535. |
protocol | tcp (the default), udp, http, or https. http is a plaintext HTTP/1.1+ listener; https is the same but the app terminates TLS on the port itself (it serves HTTPS, consuming tls.cert/tls.key). Both are eligible for an HTTP probe. Declare https when your app serves TLS — the platform then knows the scheme rather than guessing it, and a plaintext http endpoint is refused public exposure (Public exposure). |
probe | Optional readiness check — exactly one of tcp, http, or command, plus optional interval and timeout. |
serve | primary (the default) or spread — how many nodes of a scaled pool answer for this endpoint. See below. |
Endpoint names are DNS labels — one dot-separated piece of a name, the db in db.shop.internal: lowercase letters, digits and dashes, 1–63 characters, no leading or trailing dash. Most paths select an endpoint by port and never put its name in DNS, but SRV records — DNS entries that hand back a port alongside a name — and front doors that pick a service from the hostname a client asked for do, so the constraint is enforced up front rather than caught by a rename later.
orc build validates all of this:
endpoint "Metrics" is not a DNS label (lowercase alphanumeric and '-', 1-63 characters, no leading or trailing '-')
endpoint "pg" port must be an integer in 1..=65535
endpoint "web" probe.http requires protocol "http", not "tcp"
endpoint "api" probe must declare exactly one of tcp, http, command
endpoint "pg" serve must be one of primary, spread
endpoint "metrics" port 5432 duplicates endpoint "pg" on transport "tcp"
The last one is the rule that surprises people: collisions compare transports, and http and https both count as tcp, because they are TCP listeners. So two endpoints may not share a port number unless they sit on different transports. tcp and udp on the same number coexist legally — QUIC beside TCP on 443, DNS on 53.
Serve mode: primary or spread
A pool can run one node or twenty. serve is where your package says how many of them may answer a request of a given endpoint — the one thing about spreading traffic that the deployment cannot decide for you, because only the app knows whether a second node holds the same answer.
config:
endpoints:
pg:
port: 5432 # serve: primary — the default
web:
port: 8080
protocol: http
serve: spread
| Mode | Who answers |
|---|---|
primary (default) | The slot-1 node and nobody else. While that node is not ready the endpoint's port refuses connections and its names answer nothing — never a stand-in. |
spread | Every node that passes this endpoint's probe. |
primary is the default on purpose. A package that says nothing has not said that a replica may serve a write, so a pool scaled from one node to three keeps pointing at the one node it always pointed at until you say otherwise. Declare spread when any node can serve any request of the endpoint — a stateless HTTP tier, a read-only mirror, a metrics port.
The two modes live side by side on one app. A database can serve pg as primary and metrics as spread; each port is decided on its own terms.
One rule catches people out. A pool has one set of names but many ports, and a name is a single answer for all of them. So if any endpoint of the version serves primary, the pool's plain names answer the slot-1 node alone — db.shop.internal, its service-name aliases, and its public name, everywhere. A client that looked up the bare name may dial any port the pool declares, and only slot 1 is correct for all of them. Nothing else changes: the slot names (db-1, db-2, …) still name their own nodes, SRV records still list every ready node, and delivery on each individual port is still exactly what that port's mode says. If you want the names to spread, every endpoint of the app has to spread.
While the slot-1 node is unready, a name under this rule answers empty rather than falling through to a sibling — the same fence a slot name carries. See Networking for what each name means.
Probes
A probe is a small check ORC8R repeats to decide whether an endpoint is ready to serve traffic.
| Probe | Passes when |
|---|---|
tcp: true | a connection to the port is accepted — the default for tcp, http, and https endpoints |
http: /path | a GET on that path answers 2xx or 3xx (redirects are not followed). Valid on http and https endpoints; on https the probe dials over TLS. It does not validate the certificate — it dials the node's own address, where the certificate's name cannot match, and readiness is a liveness check, not the trust boundary (that is the consumer, through ca.bundle). |
command: … | the command exits 0 |
interval and timeout take the grammar <integer>(s|m|h) — "10s", "1m", "1h". Nothing finer than a second exists. The defaults are a 10-second interval and a 5-second timeout; an endpoint goes unready after 3 consecutive failures and recovers after 1 success. A udp endpoint has no default probe: without a command probe its readiness simply follows the app's.
Bind 0.0.0.0, not loopback
Make your app listen on 0.0.0.0. An app that listens only on 127.0.0.1 will never pass its probe. This is the one that costs people an afternoon.
The probe dials the node's routable address — the same address the endpoint's consumers get from DNS. It does not dial 127.0.0.1. That is deliberate: a passing probe has to mean what the platform takes it to mean — that this node is one of the ones traffic gets sent to — and an app listening only on loopback is not reachable by anyone.
So an app that binds 127.0.0.1 reports unready forever, its name answers nothing, and the pool looks healthy while serving no traffic. Configure the app to listen on 0.0.0.0 (or [::]), and let the platform's default-deny policy be what keeps the port private — privacy is the deployment's job, not your listen address's. See Project and organization exposure.
Probe failures are recorded verbatim, so the status tells you which of the two problems you have:
connect to port 5432 failed: Connection refused
connect to port 5432 timed out
http probe /healthz returned 503
probe command exited with exit status: 1
A probe command runs in the app's working directory with the app's start-time environment. One trap: parameters delivered as files with a startup lifetime are deleted once the app has started, so a probe command must not depend on a *_FILE variable. Read what you need at start, or probe over the port.
Values the platform fills in
Some parameters are not typed in by a person filling a form — a peer's name, a trust bundle, a certificate. Declare them in config.params with an x-source, and the platform fills them in on the node at deploy:
config:
params:
type: object
properties:
slot:
type: string
title: Runtime slot
x-source:
kind: pool.slot
writer:
type: string
title: Writer address
x-source:
kind: peers.first
tls_ca:
type: string
title: TLS CA bundle
contentMediaType: application/x-pem-file
x-source:
kind: ca.bundle
tls_cert:
type: string
title: TLS certificate
contentMediaType: application/x-pem-file
x-source:
kind: tls.cert
params:
purpose: server_tls
tls_key:
type: string
title: TLS private key
contentMediaType: application/x-pem-file
x-source:
kind: tls.key
params:
pair: tls_cert
A slot is a pool node's stable identity, and slot 1 is the writer by convention; Networking overview has the rest. A leaf is the certificate issued to your app itself, as opposed to the chain that signed it.
kind | params | What arrives |
|---|---|---|
pool.slot | — | the node's own runtime slot as a decimal string; 1 is the writer by convention |
peers.first | pool, project | the slot-1 node's DNS name, {pool}-1.{project}.{suffix} |
peers.gossip | pool, project | the pool's plain name — one name that means the pool as a whole, answering per its serve modes (above) |
peers.any | pool, project | the plain name too; where it spreads, the resolver rotates its answers |
peers.all | pool, project | one slot name per allocated slot, newline-separated, delivered as a file |
ca.bundle | scope: project (default) or org | the trust chain, PEM |
tls.cert | purpose: client_mtls (default) or server_tls; optional sans | a signed leaf plus its issuing chain, PEM |
tls.key | pair: the sibling tls.cert property's name | the matching private key, PEM — always file-backed and implicitly sensitive |
Three things about these are worth internalising.
peers.* hand you names, not addresses. That is what makes them stay correct: nodes are replaced, slots are reused, and the next DNS answer absorbs the change without re-resolving the parameter or restarting your app. Resolve them at connect time; do not parse them, cache them, or split them on a delimiter.
A name that answers nothing is the fence, not an error. peers.first resolves to the slot-1 name whether or not a healthy slot-1 node exists — while none does, the name answers empty and your connection attempt fails. The plain name of a primary pool is fenced the same way. That is the writer fence working as designed. Retry; do not fall back to another name.
File-backed delivery. A parameter arrives as an upper-cased environment variable, unless it is sensitive, has a contentMediaType, or is a tls.key/peers.all — those arrive as a 0600 file whose path is in <NAME>_FILE. Putting contentMediaType: application/x-pem-file on your certificate and bundle parameters is the idiom worth copying: PEM in an environment variable is unpleasant for everyone.
Certificates renew by restarting
There is no reload channel. Each converge pass — each run of the agent bringing the node in line with what it has been assigned — arms a single timer from the shortest-lived leaf it delivered: two thirds of the certificate's validity window, with jitter so pool nodes do not all restart at once. When it fires, the pass runs again, mints fresh material, and restarts the app through the ordinary pipeline.
Design for that: start fast, hold no unflushed state, and read your certificate at startup rather than watching the file. If a leaf cannot be parsed the agent warns tls leaf renewal NOT armed; this certificate will expire unattended and leaves the app running, which is the one case you have to notice yourself.
For purpose: server_tls, the names you are authorized to assert are your pool's plain name, your own slot name, and both forms of the endpoint's service name if one is chosen. Extra names are requested with sans; ask for anything outside that list and you get no certificate at all.
Tracking upstream versions
An app can declare where its versions come from, so ORC8R lists the available versions for people to choose from and you can set a default — the app tracks upstream releases without you republishing it each time. This lives in config.versions, with config.default_version naming the one selected by default:
sourceandurlsay where to look (for example, a project's GitHub releases).listis a fixed set of versions, for an app that does not track anything upstream.filternarrows what is offered —prereleaseto include or exclude pre-releases, andpatternto keep only versions matching a regular expression.
Packaging gotchas
files is a top-level key, a sibling of config, not something inside it. Its entries land in the app's working directory, which is also the working directory of every lifecycle command — so command: sh install-app.sh and command: ./bin/server both just work. Nested paths are fine; .. and absolute paths are rejected.
Values go under params. On the deployment side, an app assignment's values live under params. vars is a recipe's build-time template map and nothing else — writing it where params belongs would otherwise start your app with no configuration at all, so the request is rejected outright, with a message that says exactly this.
Stop the distribution from starting your service for you. On Debian and Ubuntu, installing a package runs its postinst, which starts the service immediately, on the distribution's default configuration, on the distribution's port, before any of your configuration exists. In a pool that is a port conflict at best, and a service that answers the probe while running nothing you configured at worst. Neutralise it inside your install phase:
# Refuse service starts for the duration of the install.
printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
chmod +x /usr/sbin/policy-rc.d
DEBIAN_FRONTEND=noninteractive apt-get install -y postgresql
rm -f /usr/sbin/policy-rc.d
# Write your configuration, then start it yourself in the start phase.
systemctl mask --now postgresql || true
The same reflex applies to the distribution's own automation: masking apt-daily.timer, apt-daily-upgrade.timer and unattended-upgrades.service is what the agent itself does before baking an image, and for the same reason — nothing should be reconfiguring a node behind the platform's back.
Wait for your dependencies in your own start script. A pool comes up in parallel and nothing sequences apps across pools for you. The pattern is a bounded retry loop around the name, not a sleep:
# Wait for the writer to exist. Until slot 1 is healthy, WRITER answers nothing.
i=0
while ! pg_isready -h "$WRITER" -q; do
i=$((i + 1))
[ "$i" -lt 120 ] || { echo "writer $WRITER never became ready" >&2; exit 1; }
sleep 2
done
exec /usr/sbin/my-service --upstream "$WRITER"
Two details make this work: the loop re-resolves the name every iteration, so a replacement writer is picked up without any parameter changing; and the failure is bounded and loud, so a genuinely broken deployment fails a deploy instead of hanging forever in starting.
A complete example
A single-writer database that serves postgres and a metrics endpoint, wires itself up from platform-filled values, and branches on its slot:
artifactType: application/vnd.orc8r.app.v1
annotations:
org.opencontainers.image.title: examplepg
org.opencontainers.image.description: Single-writer database, slot 1 is the writer
config:
params:
type: object
properties:
slot:
type: string
title: Runtime slot
x-source:
kind: pool.slot
writer:
type: string
title: Writer name
x-source:
kind: peers.first
endpoints:
pg:
port: 5432
probe:
tcp: true
interval: 5s
metrics:
port: 9187
protocol: http
probe:
http: /metrics
install:
command: sh install-examplepg.sh
start:
command: sh start-examplepg.sh
files:
- install-examplepg.sh
- start-examplepg.sh
platforms:
- os: linux
arch: amd64
start-examplepg.sh branches on $SLOT: slot 1 initialises and serves, anything else seeds from $WRITER and follows it. The platform is told nothing about roles, and does not need to be.
Related pages
- Pools — attach apps to a pool when you request nodes.
- Nodes — where apps run.
- Networking overview — the concepts behind endpoints, names, and exposure.
- Project and organization exposure — what a deployment decides about the endpoints your app declares.
- Troubleshooting networking — reading probe failures.