Wrapper around frappe_docker for reproducible ERPNext image builds and pull-based deploys on a single VPS.
Project description
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:
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.
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
The manifest — cairn.toml
One file declares one image: the Frappe source, the ordered list of apps, and build knobs. It's meant to be committed and shared — it carries no machine- or registry-specific settings.
[cairn]
image_name = "erpnext-btu-v16"
series = "v16" # the readable half of the image tag
[cairn.registry]
host = "ghcr.io"
namespace = "your-org"
[cairn.frappe]
url = "https://github.com/frappe/frappe"
ref = "version-16"
# Order matters: apps install in this order, and cairn never reorders or resolves
# dependencies for you. List every app after the apps it depends on.
[[cairn.apps]]
name = "erpnext"
url = "https://github.com/frappe/erpnext"
ref = "version-16"
[[cairn.apps]]
name = "your_custom_app"
url = "https://github.com/your-org/your_custom_app"
ref = "version-16"
[cairn.build]
python_version = "3.14.2"
node_version = "24.13.0"
install_chromium = true
[cairn.environments]
production = "production"
staging = "staging"
cairn discovers cairn.toml by walking upward from the current directory, or you can
point at one explicitly with --manifest.
Machine-local settings
Registry coordinates live in the manifest above deliberately — they describe the deployment, not the machine building it. What's genuinely machine-local (which build engine to use, an alternate transcript directory) lives in two layers instead, lowest precedence first:
~/.config/cairn/config.toml— machine-wide defaults.cairn.local.toml, beside the manifest, never committed — a per-checkout override, e.g. to build withpodmanon a laptop that has no Docker daemon.
# cairn.local.toml
engine = "podman"
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.
The target's descriptor
A target doesn't hand-author configuration. Run cairn adopt against its running
frappe_docker stack, review the descriptor it prints, and install it yourself at
/etc/cairn/environment.toml. That descriptor — not the manifest — is what cairn reconcile reads, and its presence is what marks a machine as a target at all.
How to use
On a builder:
cairn doctor # confirm the machine can actually build
cairn build # build the image declared by cairn.toml
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file datahenge_cairn-0.1.0.tar.gz.
File metadata
- Download URL: datahenge_cairn-0.1.0.tar.gz
- Upload date:
- Size: 551.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4b0dd7f14ca0b3413d8b38118a67038b24cb79fdc30390a45dc1ac97af4d1736
|
|
| MD5 |
c0c2b4bb9a8cf9084e167328d80dbb48
|
|
| BLAKE2b-256 |
d795027bd103884dce1f21d77aec6f42434d4342666c875e9db989c0465882f3
|
File details
Details for the file datahenge_cairn-0.1.0-py3-none-any.whl.
File metadata
- Download URL: datahenge_cairn-0.1.0-py3-none-any.whl
- Upload date:
- Size: 439.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7348a22ccef8017b8348dfb2fb1d2db211b0140bdc4874f3c6680580700e10f8
|
|
| MD5 |
6cd61456ee57992bf86501ae5472d212
|
|
| BLAKE2b-256 |
b1b22c5168432b8b880fd8c9b9905f132ae953f44c19dc5abbe994d3fba3614a
|