A thin, opinionated wrapper around frappe/frappe_docker
that makes running a custom ERPNext deployment (Frappe + ERPNext + custom apps) on a
single VPS reproducible, immutable, and low-thought — without ever modifying
upstream.
Distributed as datahenge-cairn on PyPI — installs the cairn command.
Two pillars: reproducible custom image builds and a pull-based deploy lifecycle (git ref → image tag → running stack, with image-only rollback). A strict data-plane boundary keeps cairn out of your databases and volumes entirely — it ships code, not data.
Cairn: a trail marker of stacked stones. Each deploy drops a durable marker (ref → resolved commits → image tag → digest) you can navigate back to.
Working, unreleased. The build and deploy commands are implemented and tested;
cairn build, push, images, prune, doctor, and vendor have been exercised against
real images and a real registry, while the pointer verbs (new-tag / retag / retire) and
the target-side reconcile are written and tested but have not yet run against live
infrastructure.
cairn is one tool with two roles, told apart by what's configured on a machine rather than by what's installed:
| Builder | Target | |
|---|---|---|
| Does | Builds images, pushes them, moves environment pointers | Polls for its pointer, pulls the image, converges the running stack |
| Reads | cairn.toml (the manifest) |
/etc/cairn/environment.toml (a descriptor, generated by cairn adopt) |
| Needs | Docker Engine v23+ or podman v4+, git |
Docker Engine + docker compose |
| Credential | push access to the registry | pull-only |
The same pip install datahenge-cairn gives you every command either role needs — a
target simply never has a reason to run the build-only commands, and its pull-only
registry credential means it couldn't push or retag even if asked.
For a machine anything else depends on — a client's builder or target VPS, not a laptop you alone operate — install system-wide rather than into your own account:
# if you don't already have it
sudo apt install pipx sudo pipx install --global datahenge-cairnThis matters specifically if you're a consultant rather than an in-house operator:
pipx --global installs to a shared system location (/opt/pipx by default), not under
your personal home directory. A plain personal pip install/pipx install works too and
is fine for a builder you alone use — but a client's production systemd timers shouldn't
depend on your individual account still existing.
pip install datahenge-cairn # fine for a personal/experimental install
Either way, that's it for the cairn command itself, on either a builder or a target. It
carries everything a build needs, including the vendored image-build recipe.
To actually build an image, the machine additionally needs:
- Docker Engine v23+ (with
buildx) or podman v4+ — auto-detected, docker preferred when both are present. git— every manifest ref is resolved to a commit at build time.
Run cairn doctor after installing to check all of this at once — it reports every
missing prerequisite in one pass rather than failing partway into a long build.
To run a target (the machine that actually serves ERPNext), you additionally need
Docker Engine and docker compose v2, and a Frappe/ERPNext stack already running via
frappe_docker — cairn converges an existing deployment, it doesn't create one from
nothing.
For standing up a fresh builder or target VPS — not just the cairn command, but the
registry, TLS certificate, systemd timers, and (for a target) a pre-install backup —
cairn-provision installs alongside cairn automatically; there's no separate download or
checkout needed. Run it once with root, from the directory that will hold (or already
holds) your deployment's cairn.toml:
sudo cairn-provision --role builder --dry-run # see what it would do first
sudo cairn-provision --role builder
--role is builder, target, or both (one box doing each — the common starting
point). It needs root on every role: a builder writes a TLS certificate and trusts it
system-wide, and both roles install systemd units under /etc/systemd/system; a target
additionally writes its descriptor under /etc/cairn. --dry-run prints every command it
would run and writes nothing, so you can review it before handing over root. It is
idempotent — safe to re-run — and never silently overwrites a file it didn't create;
anything it would replace is kept alongside, renamed.
It also shares /etc/cairn with a group by default (cairn-admins, or --admin-group <name>) so a client box with several operators can edit /etc/cairn/builder.toml without
everyone needing sudo — see CONFIGURATION.md.
--no-admin-group skips this and leaves /etc/cairn exactly as found.
On a builder (or --role both), one of these stages stands up a small supporting
container: a local OCI registry (the standard registry:2 image) that builds push to and
targets pull from, bound to 127.0.0.1 over a self-signed, trusted certificate. This is not
"cairn running as a container" — cairn itself stays a plain CLI process on the host. The
registry is its own Docker Compose project (cairn-registry, at /opt/cairn-registry),
entirely separate from your site's own deployment — whatever Compose project already runs
your ERPNext stack, which cairn-provision never creates and only ever adopts. That
separation is why, once both are running on a --role both box, the descriptor stage
needs an explicit --project <name> naming your site: with two Compose projects present,
cairn refuses to guess which one is the deployment to describe.
Contributing to cairn itself, or re-syncing the vendored upstream, needs a checkout:
git clone https://github.com/Datahenge/cairn.git
cd cairn
python3 -m venv .venv
.venv/bin/pip install --editable '.[dev]'
The dev extra adds ventwig (vendoring), ruff, and pytest — none of which a normal
install needs.
One manifest declares the image (cairn.toml, committed with the deployment); machine-
local build settings, if you need any, live separately and are never shared:
# cairn.toml
[cairn]
image_name = "erpnext-btu-v16"
series = "v16"
[cairn.registry]
host = "ghcr.io"
namespace = "your-org"
[cairn.frappe]
url = "https://github.com/frappe/frappe"
ref = "version-16"
[[cairn.apps]]
name = "erpnext"
url = "https://github.com/frappe/erpnext"
ref = "version-16"Every command names its manifest explicitly — --manifest <path>, or $CAIRN_MANIFEST if
you'd rather not repeat the flag. cairn never searches a directory for one: on a shared
machine, "the nearest cairn.toml" is a silent way to act on the wrong deployment, not a
convenience. There's no scaffolding command either — you hand-write the manifest, starting
from the example above.
See CONFIGURATION.md for the full manifest schema, the
machine-local /etc/cairn/builder.toml layer and its CAIRN_* environment-variable
overrides (what each key means, how they're created, and how precedence works), sharing
/etc/cairn across several operators, and how a target's /etc/cairn/environment.toml
descriptor comes from cairn adopt rather than being hand-authored.
Which registry you use, and who owns the credential, is worth thinking about deliberately
— especially when you're building images for a client. See
ABOUT_REGISTRIES.md for the tradeoffs, and
ABOUT_GHCR.md for GitHub's registry specifically. cairn itself is
registry-agnostic and stores no credentials — authenticate with docker login or podman login before pushing.
The examples below assume $CAIRN_MANIFEST is already exported for the session (e.g.
export CAIRN_MANIFEST=/srv/acme/cairn.toml) — add --manifest <path> to any of them
instead if you'd rather not.
On a builder:
cairn doctor # confirm the machine can actually build
cairn build # build the image declared by the manifest
cairn build --push # ...and upload it
cairn images --local # what's on this machine, and which builds are superseded
cairn prune # remove superseded local images (keeps build-cache layers)
Moving an environment's pointer — which is how you deploy, promote, or roll back — never rebuilds or re-pulls anything; it just writes a tag in the registry:
cairn new-tag staging --latest # point staging at the newest build
cairn retag production --from staging --yes # promote staging's image to production
cairn retag production --previous # roll back production one image
cairn images # what the registry holds, and which tags point where
On a target:
cairn adopt # describe this host's running stack (one-time, or after a manual change)
cairn systemd-units # print the reconcile service + timer; review, then install them
cairn reconcile --dry-run # see what would change
cairn reconcile # converge to whatever the environment's pointer says
reconcile is idempotent and meant to run on a timer — with nothing to do, it does
nothing. It never rolls back on failure; it stops and reports, because a failed bench migrate is not something to silently reverse.
cairn builds an image and puts it in a container registry; your deployment targets pull from there. cairn is registry-agnostic and assumes nothing — but which registry is not a neutral choice when you build software for clients:
📦 ABOUT_REGISTRIES.md — start here. The image belongs in the account that owns the source; your credential should reach the engagement's images and nothing else; and what each option costs at ERPNext image sizes. Includes what to ask a client for.
🐙 ABOUT_GHCR.md — GitHub's registry in detail: tokens, scopes, how narrow access can be, visibility, and the deletion rule that is genuinely surprising. One option among several, not the default.
You should never be the sole owner of a client's image. If the relationship ends, they must still be able to deploy and roll back software they own. cairn is built so the registry can be an account you do not control, and so your push credential can be scoped to one repository.
| Doc | Contents |
|---|---|
| docs/00-project-scope.md | Purpose, pillars, what it is/isn't, principles |
| docs/01-decisions-closed.md | Settled decisions with rationale |
| docs/02-decisions-open.md | Unresolved questions + current leans |
| docs/03-discussion-log.md | Chronological design reasoning |
src/cairn/vendored/frappe_docker/ is a pinned, read-only copy of upstream, vendored as
plain committed files via ventwig and never
edited by hand. It lives inside the cairn package itself, so it's part of every install
— pip or checkout alike. It's pinned to upstream release tag v3.2.1.
cairn vendor status # verify the vendored tree is clean / unmodified
cairn vendor sync # re-materialize from the pinned ref (a checkout only)
To upgrade upstream: bump ref in pyproject.toml, cairn vendor sync, review the diff,
test-build, then commit — including the regenerated frappe_docker.pin.toml alongside it.