@rcpooley/concord
v0.6.3
Published
Declarative config reconciler: edit a typed config repo, push it, and your hosts converge. Also exports the schema library config repos import.
Maintainers
Readme
Concord
A declarative config reconciler. You describe your hosts in a typed config repo (containers, files, packages, systemd units, git checkouts, DNS records) and push it. A small daemon pulls the repo, plans the difference against reality and applies it.
It does not replace the services it drives. Docker keeps building and supervising containers; systemd keeps supervising units; your DNS provider keeps serving DNS. Concord says what should exist and drives their interfaces until it does. Every module is a small, replaceable driver, so nothing home-rolled is load-bearing.
This package is two things:
- the reconciler: the
concordCLI and theconcord-daemonthat runs it on a loop; - the schema library your config repo imports, as types only, to get a typechecked config.
The package is scoped (
@rcpooley/concord) because the bare nameconcordon the public npm registry is an unrelated package. Always run the CLI through an install (npm run …, or the global binary), nevernpx concord. npx falls back to the registry when a local bin is missing.
How it works
- One primary host runs the daemon. It reaches every other host over ssh
(or locally, for itself), so the other hosts need sshd, a key the primary
trusts and the tools the modules drive (Docker, systemd, git). They don't
need Concord itself. Concord reuses one ssh connection per host; for a host
reached through Tailscale SSH, set
multiplex: falseon it, because Tailscale SSH sometimes loses a command's exit status on a reused connection. - The config is a git repo you own. It default-exports a
ConcordConfigfromconcord.ts: a host registry and an ordered list of modules. - Secrets are age-encrypted in the config repo. Anyone with the public key
(
age.pub, committed) can add a secret. Only the daemon holds the private key, in its environment. - The daemon reconciles on an interval (default 15 minutes), and early
when asked: on
SIGHUP(systemctl reload concord) or on a GitHub push webhook. Each cycle pulls the config, evaluates it, plans every module in order, and applies. Order matters only within a host: a failure stops the rest of that host's modules (its sweep included), while other hosts, and the modules with no host, carry on. The daemon never claims convergence for a host that errored; it logs the failure, lists what was not evaluated, and tries again next cycle. - How Concord runs is never decided by the config. Every daemon setting is an environment variable, so a broken config can't take away the mechanism that would fix it.
Start a config repo
Your config is a git repo you own. Scaffold one with:
npx @rcpooley/concord new my-config # or `concord new` once installedIt writes a small working config built on
@rcpooley/concord-kit
(one machine, one example app, no DNS), pinned to this exact version of both
packages, then runs npm install and git init. --age-pub age1… writes the
daemon's public key to age.pub; --no-install and --no-git skip those steps.
Install
On the host that will run the daemon, as the user it should run as (not
root). That user needs passwordless sudo, and the host needs systemd, git
and Node 20 or newer.
sudo npm install -g @rcpooley/concord # or without sudo, if your npm prefix is user-owned (nvm)
concord initconcord init checks those prerequisites, then:
- writes
/etc/concord.env(root-only, 0600) with a new age key, a new API token and an emptyCONCORD_CONFIG_REPO, and prints the key's public half for your config repo'sage.pub; - writes
/etc/systemd/system/concord.servicefor this user and this install of node; - enables and starts the service, which waits until it has a config repo.
Then point it at your config repo and restart:
sudoedit /etc/concord.env # set CONCORD_CONFIG_REPO
sudo systemctl restart concord
journalctl -u concord -fOr pass --config-repo <url> to concord init to skip that step. The daemon
needs read access to the repo, so use a URL its user can already fetch, for
example over ssh with a deploy key.
concord init is safe to re-run. An existing env file is never rewritten, since
it holds the key your secrets are encrypted to; settings an older install lacks
(the API token) are appended. An existing unit that differs
(after moving node, say) is replaced only with --replace-unit. --dry-run
shows what it would do.
Usage
# Writing a config (on your own machine, in the config repo)
concord new DIR # scaffold a config repo
concord secret set KEY # encrypt stdin into secrets.ts (needs only age.pub)
concord secret list # list declared secret names
# Talking to a running daemon (on the server, or anywhere that can reach it)
concord login URL # save the daemon's address and API token
concord status # reconciling or idle, version, next run
concord runs [ID] # recent reconciles, or one with its log
concord reconcile --wait # reconcile now and follow it to the end
concord plan BRANCH # what merging BRANCH would change
concord plans # recent plans
# On the server
concord init # install the daemon
concord plan --local # plan in this process (needs the age key)
concord run # pull, plan and apply once, outside the daemonconcord --help lists every option. Commands that talk to a daemon find it
from --server/--token, then CONCORD_SERVER/CONCORD_TOKEN, then the saved
login, then — on the server itself — /etc/concord.env (read with sudo), so
there they need no setup.
Daemon environment (/etc/concord.env):
| Var | Purpose |
|---|---|
| CONCORD_CONFIG_REPO | git URL of the config repo (required) |
| CONCORD_AGE_KEY | age private key, which decrypts secrets at runtime (required) |
| CONCORD_API_TOKEN | the API's bearer token; without one there is no API or UI. concord init generates it |
| CONCORD_LISTEN | API + UI addresses, comma-separated host:port (default 127.0.0.1:7600) |
| CONCORD_HOOK_SECRET | GitHub webhook HMAC secret; unset means no webhook |
| CONCORD_HOOK_LISTEN | webhook addresses (default 127.0.0.1:7601) |
| CONCORD_INTERVAL_SECONDS | seconds between reconciles (default 900, minimum 30) |
| CONCORD_HOME | working dir, default ~/.concord |
| CONCORD_CONFIG_DIR | override the config checkout path |
| CONCORD_FRAME_ANCESTORS | origins allowed to embed the UI in a frame, e.g. https://dash.example.com (default: none). Exact origins only: a page that can frame the UI can trick clicks onto it |
| CONCORD_SECRETS_PUSH_KEY | ssh key with write access to the config repo; turns on adding secrets from the UI (see below) |
| CONCORD_SECRETS_PUSH_URL | ssh URL to push secrets to, when CONCORD_CONFIG_REPO is https |
Running the daemon
The daemon runs under systemd as an ordinary user with passwordless sudo
(the apt, file and systemd modules need it). bin/concord.service is the
template concord init renders the unit from. It reads its whole
configuration from /etc/concord.env (mode 0600, because it holds the age
key and the API token).
Until CONCORD_CONFIG_REPO and CONCORD_AGE_KEY are both set, the daemon
waits. It logs which ones are missing and does nothing else. It doesn't
exit, so systemd doesn't restart it in a loop. Set them, then
sudo systemctl restart concord. A reload won't do it, because systemd reads
the environment file only at start.
Day to day:
| | |
|---|---|
| Status | concord status, concord runs, or the web UI |
| Logs | journalctl -u concord.service |
| Reconcile now | concord reconcile, or sudo systemctl reload concord (SIGHUP to the daemon only). Never systemctl kill -s HUP, which signals every process in the unit and kills an apply in flight. |
| Stop | sudo systemctl stop concord finishes the cycle in flight first (up to 5 minutes) |
| Update | Concord never updates itself. The daemon logs a warning each cycle when a newer version exists: sudo npm install -g @rcpooley/concord@<version>, then sudo systemctl restart concord. |
The two servers: API + UI, and the webhook
The daemon serves HTTP on two separate servers, so access level follows the port:
| | API + UI | Webhook |
|---|---|---|
| Listens on | CONCORD_LISTEN (default 127.0.0.1:7600) | CONCORD_HOOK_LISTEN (default 127.0.0.1:7601) |
| Serves | the web UI, /api/*, /healthz | POST /hook, /healthz — nothing else |
| Auth | Authorization: Bearer $CONCORD_API_TOKEN on every /api request | the GitHub HMAC signature |
| Runs when | CONCORD_API_TOKEN is set | CONCORD_HOOK_SECRET is set |
| Exposure | private only: loopback, an ssh tunnel, a VPN address | the only one that may face the internet |
An address that can't be bound yet (a VPN address before the VPN is up at boot) is retried every 30 seconds. Neither server is ever load-bearing: if one is down, the UI is missing or deploys are slower, but the interval still reconciles.
Reaching the UI. On the server, it's at http://127.0.0.1:7600. From your
own machine, the simplest is an ssh tunnel:
ssh -L 7600:127.0.0.1:7600 you@server # then open http://127.0.0.1:7600
concord login http://127.0.0.1:7600 # and the CLI works through it tooOr add a private (VPN) address to CONCORD_LISTEN, or put it behind a reverse
proxy with TLS. A proxy may add the Authorization header itself, in which case
neither the UI nor the CLI asks for a token through it. Never expose the API
on a public address without TLS: the token is a bearer credential.
The webhook is optional. With CONCORD_HOOK_SECRET set, point a GitHub
(org-level) push webhook at https://<public host>/hook, routed by your reverse
proxy to the webhook server. The daemon verifies the HMAC and reconciles; it
never parses the payload, because Concord resolves every tracked ref itself, so
one org-level webhook covers every repo. If the route breaks, deploys are
slower, never lost.
Planning a change
Work on a branch, push it, plan it, then merge it. Merging is the deploy.
git checkout -b my-change origin/main # in your config repo
# edit, npm run typecheck, commit
git push -u origin my-change
concord plan my-change --sha "$(git rev-parse HEAD)"
# read the changes, then merge my-change into main- A plan is the whole desired state of the branch, so a branch behind
mainis refused, because it would show someone else's change being undone. Rebase ontoorigin/mainand push again. --shais optional; when given, the plan is refused unless the branch is at it.- The command exits 0 when the plan ran (with or without changes) and 1 when it
was refused or failed.
--jsonprints the whole plan. - Plans run one at a time, in a child
concord plan --localagainst a clone of their own ($CONCORD_HOME/plans), never the daemon's checkout.
The API
All under the API server, all needing the bearer token. Request and response
types are in src/api.ts.
| | |
|---|---|
| GET /api/status | idle or reconciling, next run, version and staleness |
| GET /api/runs?limit=N, /api/runs/<id> | recent reconciles; the detail has the log |
| POST /api/reconcile | reconcile now (started, or queued behind a run in flight) |
| POST /api/plans?wait=S {branch, sha?} | queue a plan (202); 400 bad input, 429 queue full. wait holds the request until it finishes (at most 600 s) |
| GET /api/plans, /api/plans/<id>?wait=S | recent plans; the detail has the changes per module and the log |
A plan's status is queued, running, planned, refused (behind main, no
such branch, sha mismatch; see error) or failed (a module failed, it timed
out, or it crashed). When a module failed, result still holds what the other
hosts planned, and result.skipped names what was not evaluated.
result.modules[].ops are the changes; ahead is what the
branch adds over main.
| GET /api/secrets | secret names on main, the files that use each, the daemon's age recipient, recent changes. Never a value |
| POST /api/secrets {name, ciphertext} | stage an add or rotate: commit it on secrets/<name>, push, plan (201) |
| POST /api/secrets/<id>/deploy | push the planned commit to main; if main moved, rebuild and re-plan instead |
| POST /api/secrets/<id>/plan, /discard | plan a staged change again, or drop it and its branch |
The web UI is a separate client of this API (ui/, Vite + React), built into
the package and served by the API server.
Adding secrets from the web UI
The UI's Secrets page adds or rotates a secret without the value passing
through a terminal, a shell history or a chat with an agent. It is the same
operation as concord secret set, done in three steps:
- The browser encrypts the value to the daemon's age key, and sends only the ciphertext. The daemon checks that it decrypts to something non-empty, then drops the plaintext.
- The daemon commits the change on its own branch (
secrets/<name>, built onmain), in a clone of its own ($CONCORD_HOME/secrets), pushes it, and plans it. The page shows what the plan would change, for example which containers a rotation recreates. - Deploy pushes that commit to
mainas a fast-forward, never a force, and asks for a reconcile. Ifmainmoved since the plan, the change is rebuilt on the newmainand planned again, and you deploy after reading the new plan.
It is write-only. No endpoint returns a value, and none can be added
without decrypting in the API, which this code never does. Being able to call
these endpoints gives no more than holding age.pub already does: you can add
or replace a secret, but not read one.
Adding needs push access to the config repo. Without it the page still lists names and where they are used. Give the daemon a deploy key with write access to the config repo only, and tell it where the key is:
# on the server, as the daemon's user
ssh-keygen -t ed25519 -N '' -C concord-secrets -f ~/.ssh/concord-config-push
# add ~/.ssh/concord-config-push.pub to the config repo as a deploy key with write access,
# and make sure the git host is in ~/.ssh/known_hosts (the push runs with BatchMode=yes)
sudoedit /etc/concord.env
# CONCORD_SECRETS_PUSH_KEY=/home/<user>/.ssh/concord-config-push
# [email protected]:<owner>/<config-repo>.git # needed when CONCORD_CONFIG_REPO is https
sudo systemctl restart concordThe page also refuses to add anything when the repo's age.pub is not the
daemon's key, because a secret added there would not match the others.
Authoring a config repo
A config repo needs this package as a dev dependency (for the types and the
CLI), TypeScript, and an age.pub:
git clone <your config repo> && cd <it>
npm install
printf '%s' 'value' | npm run secret -- set my-key # with "secret": "concord secret" in package.json
npm run typecheckCONCORD_CONFIG_DIR is unnecessary there: the CLI uses the current directory
when it contains an age.pub. Adding a secret needs only the public key, so
anyone (or any agent) can author a config without being able to read it.
Runtime dependencies
A config may import helper libraries at runtime (a package of conventions that
expands into modules, for example). Put them in dependencies and commit
package-lock.json. Before evaluating concord.ts, the daemon (and concord
plan/run) installs them into the config checkout with:
npm ci --omit=dev --ignore-scripts --no-audit --no-fund- Only when
package.jsonor the lockfile changed; otherwise npm isn't run. - No lockfile, no install. Runtime dependencies without a committed
package-lock.jsonfail the cycle, so the daemon runs exactly what you tested. - Install scripts never run, and dev dependencies (this package, TypeScript) are not installed.
- A failed install fails the cycle before anything is planned or applied.
- Whatever it imports,
concord.tsmust still evaluate to plain data.
A config with no dependencies (only this package's types) installs nothing.
@rcpooley/concord-kit
is an opinionated library of this kind, from the same repo: apps as one file
each, machines that derive their base layers, a generated nginx edge, and
assemble() to put every module in order.
How the config crosses the boundary
The config repo exports plain data, not class instances:
import type { ConcordConfig } from '@rcpooley/concord' // erased at compile time
import { secrets } from './secrets.js'
const hosts = {
primary: { ip: '127.0.0.1', user: 'deploy', transport: 'local' },
web1: { ip: '10.0.0.12', user: 'deploy' }, // reached over ssh
} as const
export default {
schemaVersion: 8,
hosts,
modules: [
{
type: 'porkbun', id: 'porkbun',
apiKey: secrets['porkbun-api-key'], secretKey: secrets['porkbun-secret-key'],
zones: { /* … */ },
},
],
} satisfies ConcordConfig<typeof hosts>import type vanishes during compilation, so the config repo has zero runtime
dependency on this package. The reconciler reads inert data, validates it with
zod, and builds its own module instances via registry.ts.
That property is load-bearing. An earlier design passed class instances across
this boundary, which meant the config's copy of the classes had to be forced to
resolve to the running reconciler via a symlink — and that symlink deleted the
running code when the CLI was invoked from inside a config checkout, which in
turn sent npx off to execute an unrelated package from the public registry.
Data across the boundary, instances built here.
It also means the resolved config is plain JSON you can dump and inspect.
Two DNS modules, two ownership rules
porkbun and cloudflare diff DNS the same way but disagree about what a zone
IS, and the difference decides what gets deleted.
porkbun — a declared zone is the whole truth. A live record the config
does not declare is removed. A zone absent from zones is never touched at all.
Porkbun's API answers a few percent of requests with a bare 503, so reads are
retried up to three times (logged as ↻); writes are not, since the next run
makes up a missed one.
cloudflare — a declared zone is shared. Every record the module writes
carries a comment marked concord:<id>, and only marked records are ever
edited or deleted; anything else in the zone is reported once per run and left
alone. It is the systemd sweeper's rule (X-Concord=1) rather than the docker
sweeper's: the module can only ever remove what it created.
That is what lets a human co-own a zone — add a record in the dashboard and Concord will not touch it. The cost is that Concord will not adopt one either: declaring a record that already exists unowned fails the plan with a message naming it, rather than silently taking it over. Delete it in the dashboard, or stop declaring it.
Writing a module
export class MyModule extends Module<MyState, MyOp> {
readonly kind = 'mymodule'
async desired(): Promise<MyState> { /* from this.config */ }
async read(): Promise<MyState> { /* enumerate the live target */ }
diff(actual, desired): MyOp[] { /* pure, no I/O */ }
async apply(ops): Promise<void> { /* call the service's API */ }
}A module declares its config schema once, in zod; the TypeScript type is
inferred from it (z.infer), so the author-time type and the runtime validator
cannot drift. .strict() is what rejects unknown keys. Register the class in
registry.ts and add the schema to the union in schema.ts — the registry is a
mapped type over the union, so forgetting one fails to compile.
Rules that are not negotiable:
read()is the entry requirement. A module that cannot enumerate its target does not get to be a module. That is what buys plan-before-apply, drift detection, and safe adoption.- Every op declares its
kind(create/update/delete/custom), so the runner can count, render, and later gate destructive changes without knowing anything about the domain. diff()is pure. No network calls. It must be safe to run constantly.- Stable instance
id, passed to the constructor. Two instances of one module have to be distinguishable in plans and logs.
Bringing a module online against existing production
- Implement
read()anddiff(). Leaveapply()throwingNotImplementedError. - Run
concord planand read the output. - Seed the config from what
read()returned. - Iterate until the plan is empty.
- Only then implement
apply().
An empty plan against untouched production is the acceptance test that the module understands reality before it is permitted to change it.
Releasing
Releases are manual. main can run ahead of the published version, and a
running daemon only reports itself out of date once a newer version is
published.
This repo holds two packages released in lockstep: @rcpooley/concord (the
root) and @rcpooley/concord-kit (kit/). npm version bumps both.
git checkout main && git pull
npm version patch # or minor / major; bumps both packages, commits, tags vX.Y.Z
git push --follow-tags
npm publish # @rcpooley/concord (runs the tests first)
npm publish ./kit # @rcpooley/concord-kitBump the major version whenever SCHEMA_VERSION changes: configs written
for the old schema are rejected by the new reconciler until they are updated.
Then update each daemon when you choose to:
sudo npm install -g @rcpooley/[email protected]
sudo systemctl restart concord