Self-host reference: the local stack
Audience: site operator / MSP admin — you host Marula yourself and own its deployment.
Allocation: deploy/docker-compose.yml is the canonical definition of the
local stack (Postgres, Redis, schema migration, core-api, worker, web
console); the header comments there are the
ground truth for every flag listed below. What follows is the descriptive
map — upgrade procedures live in How to upgrade
and the backup/restore runbook.
Services and ports
| Service | Port | Notes |
|---|---|---|
| Postgres (pg18 + pgvector) | 15432 | user/db marula |
| Redis (Valkey) | 16379 | noeviction, 192 MB cap |
| core-api | 8080 | the single write surface |
| worker | 8082 | health port |
| web console | 3000 | NEXT_PUBLIC_API_URL baked at build |
| Garage (stage2 profile) | 3900-3903 | S3-compatible object storage (S3 API 3900, admin 3903) |
| Mailpit (stage2 profile) | 1025 / 8025 | SMTP sink + web UI |
| otel-collector (observability profile) | 4317 / 4318 / 18899 | OTLP gRPC/HTTP + Prometheus exposition |
| Tempo (observability profile) | 3200 | traces |
| Loki (observability profile) | 3100 | logs |
| Grafana (observability profile) | 3001 | preprovisioned datasources |
Bring-up
For a demo or a scratch box, scripts/demo-up.sh is the one-command
path: Postgres + Valkey from this compose file on the ports below, the
migrations, the seeded demo org, and core-api / worker / web
on the host. How to run the local demo stack
walks it, including the desktop alternative that needs no Docker.
To run every service in containers instead:
git clone https://github.com/Marlin-Hofmeyer/marula && cd marula
docker compose -f deploy/docker-compose.yml up --buildMigrations run once as the table-owner role before core-api starts;
core-api itself never holds that role (see "role split" below). The web
console bakes NEXT_PUBLIC_API_URL into the client bundle at build time —
behind your own domain, set it to the browser-reachable API URL, not a
container service name.
First bring-up applies the full forward-only migration chain. The demo
script (scripts/demo-up.sh) and the desktop test app additionally seed
the demo org the tutorials use
(ADR 0005), the script via
marula-core-cli seed-demo --force.
Profiles
- Default: everything above without the stage-2 slots.
--profile stage2adds object storage (Garage) and the Mailpit email sink. Default object storage is the local-fs none mode, so plainupneeds no Garage; flipOBJECT_STORE=s3when you enable the profile.--profile observabilityadds the OTel collector, Tempo, Loki, Grafana. Telemetry export is disabled by default: it activates only whenOTEL_EXPORTER_OTLP_ENDPOINTis set (e.g.http://otel-collector:4318) together with the profile. Grafana ships preprovisioned Tempo/Loki/ MarulaMetrics datasources plus a starter dashboard.
Role split (why two database roles)
Migrations and the worker connect as the table owner (marula) — the
worker's outbox relay must read every organization's rows and therefore
bypasses row-level security. The API connects as marula_app
(RLS-constrained) so tenant isolation is enforced first by the database
itself, with the application as the second line. marula_app and its
grants are provisioned by migration 0114 (which renamed the role from its
pre-rebrand name), and its password is applied from MARULA_APP_PASSWORD on
every bring-up. Do not collapse the two roles.
Secrets
The compose file ships no secret value. It requires eight, and compose
refuses to render without them: JWT_SECRET, MARULA_SECRET_KEY (worker and
core-api must match, or sealed webhook secrets become undecryptable),
MARULA_INTERNAL_TOKEN, EMAIL_INTERNAL_TOKEN, POSTGRES_PASSWORD,
MARULA_APP_PASSWORD, GARAGE_RPC_SECRET and GLITCHTIP_SECRET_KEY.
Generate each one (openssl rand -hex 32) before the first boot, and keep
them somewhere the host can read at teardown time — down interpolates the
same file. One is not simply generated: POSTGRES_PASSWORD is applied by
initdb on the volume's first boot, so an existing volume keeps the password
it was initialised with. MARULA_APP_PASSWORD is generated like the others and
applied to the marula_app role by the app-role-password one-shot on every
bring-up — after migrations, before core-api — so a fresh volume ends with
your value rather than the literal migration 0114 mints, and a rotation is an
env change plus up (deploy/README.md, "Rotating the app role password").
PgBouncer's auth file is rendered from those two values when the pooler
starts, so no database password is checked in for it. A laptop stack can take
the repository's published dev values instead (--env-file deploy/dev.env),
which the stack warns about at every boot. Set EMAIL_INBOUND_HMAC_SECRET from
the start as well: the inbound webhook mail path stays disabled until it is.
Optional: asymmetric access-token signing. Set
MARULA_JWT_EDDSA_PRIVATE_KEY and MARULA_JWT_EDDSA_PUBLIC_KEY (PEM
contents) on core-api to sign with Ed25519 instead of the shared secret.
MARULA_JWT_KID names
the key (default m1). The pair must be complete and parse, or core-api
refuses to boot. HS256 keeps verifying during a rollout, so you can
switch without logging anyone out. The authentication threat model (internal documentation) §A4 covers when to retire the
shared secret.
Container security posture
Every container in this stack runs least-privilege. The practical facts an operator needs:
Who runs. All three Marula images drop to a fixed numeric user at
build time. Core-api, worker, and the CLI run as the hardened
base's nonroot user (uid 65532), the web console as uid 1000, the backup
runner as uid 10003. Nothing runs as root except Postgres' one-time
first-boot init: the official postgres entrypoint needs root to chown its
data directory, then re-execs as postgres (uid 70 in the hardened
image) before accepting connections.
Which images. Third-party slots use Docker Hardened Images
(dhi.io/..., Apache-2.0, free for everyone) wherever the hardened
catalog covers them: Postgres+pgvector, Valkey, Grafana, Tempo, Loki,
ClamAV, and the bootstrap curl image. Garage, Mailpit, GlitchTip, Uptime Kuma, and the
OpenTelemetry collector have no hardened equivalent yet (checked
2026-09-30), so they run upstream images under the same pinning and
least-privilege rules. Note that hardened registry pulls want a docker login with a free Docker account.
What is writable. Most services mount a read-only root filesystem and
write only to their own volume or a tmpfs. Valkey writes its data volume,
the web console and the Rust services get tmpfs scratch only (core-api
additionally writes the object-store directory when OBJECT_STORE=local).
Three services keep a writable rootfs because a read-only one breaks their
own boot logic, and each says so in deploy/docker-compose.yml: Postgres
(init), ClamAV (signature database setup), Grafana (sqlite and log state).
cap_drop: [ALL] and no-new-privileges apply to every service,
exemptions included.
Volumes and permissions. If you pre-create Docker volumes or bind-mount host directories for Garage, the object store, or backups, chown them to the service uid listed above (Garage runs as 10004, backup dumps as 10003, valkey and the observability slots as 65532). A volume owned by root that a non-root uid cannot write is the most common first-boot failure after enabling a profile.
Version pinning. Every image reference is pinned: an exact tag plus a registry digest, in the three Marula Dockerfiles and every third-party slot in the compose file and Helm chart. When you bump a version, move the tag and the digest together. The digest is what actually runs.
Kubernetes. The Helm chart sets the same posture: pod-level
runAsNonRoot with per-component runAsUser and fsGroup, and per
container allowPrivilegeEscalation: false, capabilities.drop: [ALL],
seccomp RuntimeDefault, and readOnlyRootFilesystem where the service
tolerates it. The chart refuses to render unless every image reference
carries a digest (digestPinningEnforced, on by default). That gate covers
every bundled third-party image, curl bootstrap job included.