Concepts

Configuration

ant.yaml for a project, and ~/.ant/config.json for the machine.

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

KeyMeaning
versionSchema 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.
appProject display name. Required, unique per server, used in resource names.
groupOptional client-side group the project belongs to.
kindOptional. app (the default) or database, which marks a template stack in the UI.
machineDeploy target (a registered nest id, or local). Empty means local.
git_urlCanonical git repository URL (metadata).
buildWhere the source lives (context, dockerfile).
deployBuild/run settings (table below).
rollbackRelease retention and rollback hooks.
namingResource-name factors (branch).
deploymentsPer-environment overlays, keyed by environment name.

deploy keys

KeyMeaning
buildBuilder: dockerfile, nixpacks, buildpacks, railpack, or none (pre-built image).
runRuntime: container, compose, or stack (Swarm; remote only, images must be pullable).
portContainer port published to the host / routed by Caddy.
dockerfileDockerfile for the deploy stage (overrides build.dockerfile).
file / filesCompose 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).
publishloopback (default) binds 127.0.0.1; all binds every interface.
imagePre-built image reference; required when build: none.
replicasDesired instance count for compose/stack.
build_servicesRebuild compose services declaring build: on deploy (default true).
profilesdocker compose --profile names to activate.
zero_downtimeAvoid 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_imagesApp image tags retained after a deploy; falls back to rollback.keep_releases, then 10.
build_envBuild args passed to the builder (not the container). A bare KEY reads ant's env.
health_checkHTTP readiness probe: path, interval, timeout, retries.
resourcescpu and memory limits.
loggingmax_size / max_files json-file rotation.
registryRegistry 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_signingcosign verification: mode, issuer, identity, public_key. Runs on this machine and on the worker before it starts the image.
volumesrun: container mounts (short syntax or source/target/read_only/machine).
networksExtra docker networks: name, driver, subnet, external.
use_group_networkJoin the group's network (default true).
use_machine_networkJoin the machine-wide network (default false).

deploy also accepts the inline run: and file: keys shown above.

rollback

KeyMeaning
keep_releasesReleases retained for rollback (also the fallback for image retention).
pre_hookShell command run before ant trail rollback.
post_hookShell command run after a successful rollback.

naming

KeyMeaning
branchInclude the git branch in resource names. Unset means true.

Deployment overlays

Each entry under deployments: merges over the base at deploy time:

KeyMeaning
deployPer-environment deploy overrides (port, file, builder, …).
buildPer-environment build overrides (context, dockerfile).
envKEY=VALUE runtime environment.
env_fileDotenv files loaded as the lowest-precedence env source.
secretsSecret references resolved at deploy time.
domainsHostnames routed to this deployment (see Domains).
serverPer-deployment target; overrides machine: for this environment.
port_offsetAdded to deploy.port for the host bind (local, remote container, and remote compose/stack files).
auto_openOpen 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's group: (omitted when none).
  • <app>: the project's app:.
  • <env>: the deployment name (local by 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

KeyMeaning
versionConfig schema version (2).
userThe signed-in Ant account profile (node_id, display_name, email, system_user, created_at). The private key lives in ~/.ant/identity.
machinesEvery machine ant knows about, keyed by id (local is always present).
notificationsLocal notification targets: slack_webhook, discord_webhook, email, smtp (host, port, username, password, from), telegram_bot_token, chat_id, webhook_url.
relayA self-hosted iroh relay URL to use instead of the public default.
uiDashboard preferences: host, port, theme (light/dark/system), password_hash (set via ant ui passwd), refresh_seconds (0 = default 10s, -1 = off).

Machine entry keys

KeyMeaning
idStable identifier; local is reserved.
nameHuman-readable label (defaults to the hostname).
hostnameThe machine's network name, when known.
kindlocal or remote.
node_idThe machine's iroh identity (NodeID), when known.
project_dirsDirectories scanned for ant.yaml on this machine.
groupsGroups defined on this machine (see below).
caddyManaged Caddy settings (see below).
dockerHow ant talks to the runtime: mode = rootless | group | proxy | none.
networkThe machine-wide network projects opt into: name, driver, subnet, external.
usersThe colony roster (remote machines), keyed by account NodeID: node_id, display_name, email, role, system_user, added_at.
invitesPending 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.networks lists networks the project declares.
  • Its group's: when the project has a group:, it joins that group's shared network unless use_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 is ant-<machine> (e.g. ant-local), overridable at machines.<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 into machines.local;
  • flat groups, project_dirs, and caddy nest 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.

Copyright © 2026