Skip to content

Deployment

CairnCMS is a Node.js application packaged as a Docker image. The recommended deployment shape is to run the official image alongside your database and (optionally) Redis, with the platform you already use for everything else: Docker Compose on a single host, Kubernetes, ECS, Fly, Railway, Render, or any other container runtime.

This page covers how the image is built, what the cairncms init scaffold gives you, where the scaffold ends and production decisions begin, and the patterns for running CairnCMS at scale.

The CairnCMS image is published on two registries:

  • cairncms/cairncms on Docker Hub
  • ghcr.io/cairncms/cairncms on GitHub Container Registry

Tags follow the project’s release versions (for example, cairncms/cairncms:1.0.0). A latest tag tracks the latest stable release. Multi-arch images are published for linux/amd64 and linux/arm64.

The image is based on node:22-alpine, runs as the unprivileged node user, and exposes port 8055. It contains:

  • the compiled API
  • the built admin app, served by the API
  • a CLI bin (cairncms) for migrations, bootstrap, user management, and config workflows

By default the image expects state at three paths inside the container:

  • /cairncms/database — SQLite file location, when using SQLite
  • /cairncms/uploads — the local-disk storage backend
  • /cairncms/extensions — custom extensions, migrations, and email templates

The default CMD runs cairncms bootstrap (which ensures system tables exist and applies pending migrations), then cairncms start to serve requests.

The fastest way to a working local stack is the scaffold:

Terminal window
npx cairncms init my-cms

This creates my-cms/cairncms/ with three files relevant to deployment:

  • docker-compose.yml — three services: PostgreSQL with PostGIS, Redis, and CairnCMS. Mounts volumes for uploads, extensions, snapshots, and config-as-code.
  • .env — random secrets, a default admin account, and the database connection details. Gitignored.
  • README.md — quick reference for the stack.

The scaffold is for local development and content modeling. It is not a production-ready template. See the next section for what to change before deploying.

The scaffold makes choices that work locally but should be revisited for any environment beyond a developer laptop:

The scaffold’s database service uses postgis/postgis:16-3.4-alpine, which is linux/amd64 only. On ARM hosts (AWS Graviton, ARM-based Kubernetes nodes, Apple Silicon production), pull a multi-arch image instead. Plain postgres:16-alpine is multi-arch and works for any project that does not use geometry fields.

The scaffold writes random secrets to a local .env file. For production:

  • Source KEY, SECRET, DB_PASSWORD, and any provider credentials from a secret store (your platform’s secret manager, HashiCorp Vault, AWS Secrets Manager, or similar).
  • Do not commit .env to source control. The scaffold gitignores it; preserve that.
  • Rotate SECRET carefully. Rotating it invalidates all existing sessions, which is sometimes the goal but should be intentional.

The scaffold defaults PUBLIC_URL to http://localhost:<port>. Set it to the externally-reachable URL — https://cms.example.com, including scheme — before any deployment. Asset URLs, redirect targets, and email links are all built from this value.

The image does not terminate TLS. Run a reverse proxy (Caddy, Traefik, nginx) or a managed load balancer in front of CairnCMS to handle HTTPS. Set IP_TRUST_PROXY=true (the default) so CairnCMS reads X-Forwarded-For for the real client IP.

For production over HTTPS:

Terminal window
REFRESH_TOKEN_COOKIE_SECURE=true
REFRESH_TOKEN_COOKIE_SAME_SITE=strict

The scaffold defaults to false/lax for local dev. Override these explicitly in your production environment.

The scaffold’s cairncms service mounts four directories:

  • ./uploads — file bytes for the local storage backend
  • ./extensions — custom extensions, migrations, and email templates
  • ./snapshots — schema and config snapshot output (used by cairncms schema and cairncms config CLI commands)
  • ./config — config-as-code working directory

In production, these need to be backed by durable storage:

  • For uploads, prefer a remote storage backend (S3, GCS, Azure Blob, Cloudinary) over a mounted volume. Files outlive container instances; the storage backend is the durability surface, not the volume.
  • For extensions, the directory should be part of your container image or pulled in at build time, not edited in production.
  • For snapshots and config, these are output paths used by CLI workflows. Mount them only on the host where you run those commands.

If you prefer to run CairnCMS directly on a host:

  1. Install Node.js 20 (or newer) on the host.
  2. Install the CairnCMS package: npm install -g cairncms (or use the source tree if you’re building from this repo). The cairncms package is the one that registers the cairncms CLI binary.
  3. Provide the configuration through a .env file or environment variables (see Configuration).
  4. Run cairncms bootstrap once to install system tables and apply migrations.
  5. Run cairncms start (typically under a process supervisor like systemd or PM2).

The Docker image is a packaging convenience, not a hard requirement.

CairnCMS separates initialization from serving:

  • cairncms bootstrap ensures the database has the system tables, applies pending migrations, and (on first run) creates a default admin user. Idempotent — safe to run on every deploy.
  • cairncms start boots the API process. Does not run migrations on its own; it only validates that all known migrations have been applied and warns if any are missing.

The official Docker image runs bootstrap then start from its CMD, so a freshly-deployed container ends up with up-to-date schema and a running server. For platforms that expect a long-running process, this is fine. For platforms that want a separate migration step, run cairncms bootstrap as a one-shot job before scaling up the long-running service.

For Kubernetes specifically, an init container running cairncms bootstrap followed by the main container running cairncms start is the cleanest split. The init container can also act as a sanity check that fails the deploy if migrations cannot be applied.

A typical proxy configuration:

  • Forward /admin and the API endpoints (/items, /files, /auth, etc.) to CairnCMS.
  • Set Host, X-Forwarded-Proto, and X-Forwarded-For headers.
  • Cache static asset routes (/admin/assets/*) aggressively at the proxy.

Sample Caddy snippet:

cms.example.com {
reverse_proxy cairncms:8055
}

Sample nginx snippet:

server {
listen 443 ssl http2;
server_name cms.example.com;
location / {
proxy_pass http://cairncms:8055;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

CairnCMS itself is light. The platform’s resource needs are dominated by:

  • the database (sized to your dataset and concurrency)
  • file storage (sized to your asset volume)
  • transformation throughput, when serving on-the-fly image transforms

A small project (single instance, modest traffic, S3-backed storage, Postgres) runs comfortably on 1 vCPU and 1 GB RAM for the CairnCMS container. Image transformations spike CPU and memory on demand; a heavy public-facing site benefits from caching transformed assets at the CDN layer. The ASSETS_TRANSFORM_* configuration variables control concurrency limits — see Configuration.

To run CairnCMS behind a load balancer with more than one instance:

  • Database: a single shared instance; CairnCMS does not expect to be the only writer, but it is the source of truth for its system tables.
  • Storage: use a remote backend (S3-compatible, GCS, Azure, or Cloudinary). Local-disk storage does not survive container restarts and cannot be shared across instances.
  • Cache and rate limiter: switch to Redis-backed stores so limits and cached responses are shared across instances. See Configuration for CACHE_REDIS, RATE_LIMITER_REDIS, and MESSENGER_REDIS.
  • Messenger: required for cross-instance event coordination. Set MESSENGER_STORE=redis and configure MESSENGER_REDIS so all instances see the same events.

Without these, multi-instance deployments work for read-heavy workloads but exhibit drift on writes, i.e. items created on instance A may not appear in cached responses from instance B until the cache TTL elapses.