Configuration
Ant has two configuration files: one per project, one per machine.
ant.yaml (per project)
version: 1 # schema version (optional)
app: my-app # required; unique per server
group: backend # optional; the client-side group this project belongs to
kind: app # optional; "app" (default) or "database" (template stacks)
machine: prod # optional; target a registered machine (default local)
git_url: https://github.com/acme/api.git # optional; canonical repo URL
build:
context: . # build context directory, relative to the project root
dockerfile: Dockerfile # Dockerfile path, relative to context
deploy:
build: dockerfile # dockerfile | nixpacks | buildpacks | railpack | none
run: container # container | compose | stack
port: 3000 # container port exposed to the host / reverse proxy
dockerfile: Dockerfile # override build.dockerfile for the deploy stage
file: docker-compose.yml # compose file (used when run: compose)
files: [compose.yml, compose.prod.yml] # ordered compose layers
publish: loopback # loopback (default) | all, host bind address
image: ghcr.io/acme/api:1.4.2 # pre-built image; required when build: none
replicas: 2 # desired instance count (compose/stack)
build_services: true # rebuild compose services that declare build: (default true)
profiles: [worker] # docker compose profiles to activate
zero_downtime: true # start the new container and move routing before stopping the old
keep_images: 3 # app image tags retained (falls back to rollback.keep_releases)
build_env: [NODE_ENV=production, PUBLIC_URL] # build-time args (bare KEY reads the env)
health_check:
path: /health
interval: 10s
timeout: 5s
retries: 3
resources:
cpu: "0.5"
memory: 512m
logging:
max_size: 10m
max_files: 3
registry:
type: external # local | external
host: ghcr.io
repository: acme/api
username: acme
image_signing: # optional cosign verification before pull/run
mode: required # off | warn | required
issuer: https://token.actions.githubusercontent.com
identity: https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main
public_key: /path/to/cosign.pub # optional, for the key-pair flow
volumes: # run: container only
- data:/var/lib/app # named volume (namespaced to <name>-data)
- ./uploads:/app/uploads:ro
- source: cache
target: /var/cache/app
read_only: true
machine: prod # scope the mount to one nest
networks:
- name: shared
driver: bridge
subnet: 172.30.0.0/16
external: false
use_group_network: true # join the group's network (default true)
use_machine_network: false # join the machine-wide network (default false)
rollback:
keep_releases: 5 # releases retained for `ant trail rollback`
pre_hook: ./scripts/pre-rollback.sh
post_hook: ./scripts/post-rollback.sh
naming:
branch: false # omit the git branch from resource names
deployments:
dev:
deploy: { port: 4000 } # per-deployment deploy overrides (merge over the base)
build: { dockerfile: Dockerfile.dev }
server: prod # per-deployment target (overrides machine:)
port_offset: 1 # added to deploy.port for the host bind
auto_open: true # open the browser after a local deploy
env:
- LOG_LEVEL=debug
env_file: [.env.local] # lowest-precedence env source
secrets: [API_TOKEN]
domains:
- api.dev.localhost
Top-level keys
| Key | Meaning |
|---|---|
version | Schema version (currently 1). Optional. Unknown keys are rejected and a newer version is refused, so a typo or a file from a newer ant fails the load instead of silently dropping settings. |
app | Project display name. Required, unique per server, used in resource names. |
group | Optional client-side group the project belongs to. |
kind | Optional. app (the default) or database, which marks a template stack in the UI. |
machine | Deploy target (a registered nest id, or local). Empty means local. |
git_url | Canonical git repository URL (metadata). |
build | Where the source lives (context, dockerfile). |
deploy | Build/run settings (table below). |
rollback | Release retention and rollback hooks. |
naming | Resource-name factors (branch). |
deployments | Per-environment overlays, keyed by environment name. |
deploy keys
| Key | Meaning |
|---|---|
build | Builder: dockerfile, nixpacks, buildpacks, railpack, or none (pre-built image). |
run | Runtime: container, compose, or stack (Swarm; remote only, images must be pullable). |
port | Container port published to the host / routed by Caddy. |
dockerfile | Dockerfile for the deploy stage (overrides build.dockerfile). |
file / files | Compose file, or an ordered list layered as repeated -f. Used when run: compose; run defaults to container, so set both (or let ant nest edit deploy --file do it). |
publish | loopback (default) binds 127.0.0.1; all binds every interface. |
image | Pre-built image reference; required when build: none. |
replicas | Desired instance count for compose/stack. |
build_services | Rebuild compose services declaring build: on deploy (default true). |
profiles | docker compose --profile names to activate. |
zero_downtime | Avoid dropping traffic during a deploy: a remote container deploy moves Caddy to an ephemeral port before replacing the old container (needs a domain), and a stack deploy gets Swarm's start-first rolling update. Remote compose still uses the staged swap. |
keep_images | App image tags retained after a deploy; falls back to rollback.keep_releases, then 10. |
build_env | Build args passed to the builder (not the container). A bare KEY reads ant's env. |
health_check | HTTP readiness probe: path, interval, timeout, retries. |
resources | cpu and memory limits. |
logging | max_size / max_files json-file rotation. |
registry | Registry to push/pull: type, host, repository, username. A source-built remote deploy builds here, pushes <host>/<repository>/<app>:<release>, and the machine pulls it; the password comes from ANT_REGISTRY_PASSWORD and travels with the deploy as a short-lived worker-side credential, so private images pull without a login on the machine. |
image_signing | cosign verification: mode, issuer, identity, public_key. Runs on this machine and on the worker before it starts the image. |
volumes | run: container mounts (short syntax or source/target/read_only/machine). |
networks | Extra docker networks: name, driver, subnet, external. |
use_group_network | Join the group's network (default true). |
use_machine_network | Join the machine-wide network (default false). |
deploy also accepts the inline run: and file: keys shown above.
rollback
| Key | Meaning |
|---|---|
keep_releases | Releases retained for rollback (also the fallback for image retention). |
pre_hook | Shell command run before ant trail rollback. |
post_hook | Shell command run after a successful rollback. |
naming
| Key | Meaning |
|---|---|
branch | Include the git branch in resource names. Unset means true. |
Deployment overlays
Each entry under deployments: merges over the base at deploy time:
| Key | Meaning |
|---|---|
deploy | Per-environment deploy overrides (port, file, builder, …). |
build | Per-environment build overrides (context, dockerfile). |
env | KEY=VALUE runtime environment. |
env_file | Dotenv files loaded as the lowest-precedence env source. |
secrets | Secret references resolved at deploy time. |
domains | Hostnames routed to this deployment (see Domains). |
server | Per-deployment target; overrides machine: for this environment. |
port_offset | Added to deploy.port for the host bind (local, remote container, and remote compose/stack files). |
auto_open | Open http://localhost:<port> after a local deploy. |
Resource naming
Every docker resource ant creates is named from the same factors:
[<group>-]<app>-<env>[-<branch>]
<group>: the project'sgroup:(omitted when none).<app>: the project'sapp:.<env>: the deployment name (localby default).<branch>: the checked-out git branch, when the project is a work tree (omitted in a detached HEAD or outside git).
The same name is applied to containers, image repositories, compose projects,
Caddy route ownership, and the prefix of named volumes. A named volume declared
as data is created as <name>-data (bind mounts are unchanged; compose
volumes are namespaced by the compose project, which is the same name). Every
factor is lowercased and reduced to [a-z0-9-]. Names stay within 63
characters: an over-long name is truncated and given a short sha256 suffix so
two long names never collide.
Because the environment and branch are part of the name, deploying one project
from two branches, to two environments, or on two machines never collides.
Including the branch is deliberate isolation: switching branches switches to a
separate, empty volume for run: container. Set naming.branch: false in
ant.yaml to keep one set of resources per project+env across branches:
naming:
branch: false # omit the git branch from resource names
ant trail destroy and ant trail prune operate on the name for the branch you
are currently on.
~/.ant/config.json (per machine)
Schema v2. Everything about a machine nests under it:
{
"version": 2,
"user": { "node_id": "…", "display_name": "Ada", "email": "ada@example.com" },
"relay": "https://relay.example.com",
"ui": { "host": "127.0.0.1", "port": 4000, "theme": "dark", "refresh_seconds": 10 },
"notifications": { "slack_webhook": "…", "email": "ops@example.com" },
"machines": {
"local": {
"id": "local",
"kind": "local",
"caddy": { "auto_start": true },
"docker": { "mode": "rootless" },
"network": { "name": "ant-local" },
"groups": {
"backend": {
"display_name": "Backend",
"color": "#4f46e5",
"description": "API and workers",
"network": { "name": "backend" },
"domains": ["example.com"],
"env": ["LOG_LEVEL=info"],
"secrets": ["DATABASE_URL"],
"project_dirs": ["/home/me/code/backend"]
}
},
"project_dirs": ["/home/me/code/api", "/home/me/code/worker"]
},
"prod": {
"id": "prod",
"name": "prod",
"kind": "remote",
"hostname": "prod.example.com",
"node_id": "…",
"docker": { "mode": "rootless" },
"network": { "name": "ant-prod", "driver": "bridge", "subnet": "172.30.0.0/16" },
"users": { "…": { "node_id": "…", "role": "owner", "system_user": "ant-abcd1234" } },
"invites": { "nonce": { "role": "deployer", "expires_at": "2026-01-01T00:00:00Z" } }
}
}
}
Top-level keys
| Key | Meaning |
|---|---|
version | Config schema version (2). |
user | The signed-in Ant account profile (node_id, display_name, email, system_user, created_at). The private key lives in ~/.ant/identity. |
machines | Every machine ant knows about, keyed by id (local is always present). |
notifications | Local notification targets: slack_webhook, discord_webhook, email, smtp (host, port, username, password, from), telegram_bot_token, chat_id, webhook_url. |
relay | A self-hosted iroh relay URL to use instead of the public default. |
ui | Dashboard preferences: host, port, theme (light/dark/system), password_hash (set via ant ui passwd), refresh_seconds (0 = default 10s, -1 = off). |
Machine entry keys
| Key | Meaning |
|---|---|
id | Stable identifier; local is reserved. |
name | Human-readable label (defaults to the hostname). |
hostname | The machine's network name, when known. |
kind | local or remote. |
node_id | The machine's iroh identity (NodeID), when known. |
project_dirs | Directories scanned for ant.yaml on this machine. |
groups | Groups defined on this machine (see below). |
caddy | Managed Caddy settings (see below). |
docker | How ant talks to the runtime: mode = rootless | group | proxy | none. |
network | The machine-wide network projects opt into: name, driver, subnet, external. |
users | The colony roster (remote machines), keyed by account NodeID: node_id, display_name, email, role, system_user, added_at. |
invites | Pending invites (client-side bookkeeping only; the signed token is not stored): role, email, expires_at. |
The caddy block holds auto_start, admin_listen, http_listen,
https_listen, internal_tls, and acme_email. Defaults are :8080/:8443,
the internal CA, and auto-start. A change to http_listen/https_listen does
not reach an already-running proxy; the next local deploy restarts it, or run
ant nest tools caddy restart to apply it now.
A group entry holds display_name, color, description, network,
domains, env, secrets, and project_dirs. Membership comes from a
project's group: key, not from a list here.
hostname is the machine's network name; node_id is its iroh identity. Both
are optional and omitted for local. The caddy key's auto-start field is
auto_start (not enabled).
Networks
Three kinds of docker network a project can join:
- Its own:
deploy.networkslists networks the project declares. - Its group's: when the project has a
group:, it joins that group's shared network unlessuse_group_network: false. - The machine's: a single network shared by the whole machine, which any
project opts into with
use_machine_network: true. The default name isant-<machine>(e.g.ant-local), overridable atmachines.<id>.network.name.
The machine-wide network is for the case a group does not fit: a project that owns a shared service (Postgres, Redis, a queue) publishes itself on the machine network, and any other project on the same machine opts in to reach it by container name. Unlike the group network it is opt-in (default off).
# infra/db, the database project
app: db
deploy:
run: compose
use_machine_network: true
# apps/api, a consumer, not in the same group
app: api
deploy:
run: container
use_machine_network: true # reaches `postgres:5432` by name
All three are created on deploy (unless external: true) and attached to every
service; containers on the same network resolve each other by name. On a remote
nest the machine's worker creates the network and attaches the deploy, so the
same project config works whether it runs here or on a registered machine.
A group's network can also be declared attach: false (--no-attach),
which keeps the definition but stops members auto-joining.
From the CLI, toggle the machine network on a project with
ant nest edit deploy --machine-network (or --machine-network=false), and
edit the project's own networks with ant nest edit network add|remove|list.
Domains
Ant never adds a hostname for you; domains are always explicit. They are
declared per deployment, not per project. Each entry names the
hostname(s) it serves and what it routes to: a compose service and container
port, or just a port (which defaults to deploy.port for a single-container
project). A group owns one or more base domains the entries may build on; on
a local machine they default to localhost:
ant nest groups domains backend example.com example.org
The base is optional: omit it to use every group domain, name one to override, or
use a bare hostname verbatim. A deployment's own base always wins; the group's are
fallbacks. With several group domains and no base named, the entry applies to each. Set group_domain to pin one of the group's domains instead.
deployments:
local:
domains:
# Container project: routes to deploy.port.
- subdomain: api # api.example.com + api.example.org
- subdomain: eu # only the named group domain
group_domain: example.org # → eu.example.org
- path: /internal # example.com/internal + example.org/internal
- {} # example.com + example.org (as-is)
- app.other.org # absolute hostname, verbatim
# Compose: each domain names the service AND container port it forwards to.
- subdomain: shop
service: web
port: 3000
- path: /api
service: api
port: 8080
service/port are the routing target; a domain is only meaningful with what it
forwards to. scheme pins a route to http/https; strip_trailing_slash
rewrites /path/ to /path. On a remote nest the machine's Caddy serves the same
routes. Manage them with ant nest edit domain add|remove|list (--subdomain,
--domain, --path, --group-domain, --service, --port, --scheme,
--strip-slash) or the project's Domains tab.
Migration from v1
v1 configs migrate automatically in memory on load:
- a top-level
"local"object folds intomachines.local; - flat
groups,project_dirs, andcaddynest under the local machine; - a legacy group carrying a
machine:field is nested under that machine.
A legacy value of the wrong JSON type (for example a string groups, or a
group entry that is not an object) is reported instead of being dropped by the
migration.
The CLI keeps the migrated form in memory and does not rewrite the file. Saving
through the dashboard's raw config editor writes it back at version: 2 and
keeps a backup of the original.
Secrets
Build-time secrets resolve from the environment and are never baked into images
or the lockfile. Runtime secrets reach the container through the docker process
environment (a bare docker run -e KEY; compose uses --env-file on the
worker), never through stored plaintext in a compose document or command argv.
Values may span multiple lines (for example a PEM key): the process environment
carries them as-is, and compose values are written quoted and escaped. Reference
secrets by name in ant.yaml through ant nest edit; ant trail env prints
the resolved values:
ant nest edit secret add API_TOKEN
ant nest edit secret add NPM_TOKEN --build-time
ant trail env
A secret can also name a provider, used when the name is not in the environment and only resolved at deploy time (the value is never stored):
deploy:
secrets:
- API_TOKEN # from the environment by name
- name: NPM_TOKEN
build_time: true
- name: DATABASE_URL
from: op://Private/prod-db/password # 1Password CLI (`op read`)
- name: STRIPE_KEY
from: cmd:vault kv get -field=key secret/stripe # any CLI, stdout is the value
- name: LEGACY_TOKEN
from: env:OLD_TOKEN # another environment variable
op:// runs the 1Password CLI (ANT_OP_BIN overrides the binary); cmd: runs
any command and uses its trimmed stdout, which covers Vault, AWS Secrets
Manager, pass, and the like; env: reads a named environment variable. A
provider that fails is reported as a warning and the secret is skipped, never
logged. ant trail doctor reports secrets that are set neither in the
environment nor by a provider.