Skip to content

Repository files navigation

cairn

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.

Status

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.

Two roles, one install

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.

Installation

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-cairn

This 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.

Provisioning a whole machine

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.

For development

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.

Configuration

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.

Where images are pushed

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.

How to use

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.

Where your images live

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.

Design docs

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

Vendored upstream

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.

About

CI/CD application for creating custom images with frappe-docker

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages