<!--
  Self-host reference: the local stack — Marula PSA documentation
  Source: /docs/psa/v0.3/reference/self-host-local-stack
  License: CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/
  Source file: https://github.com/marulahq/marula-core/tree/main/docs/site/reference/self-host-local-stack.md
-->
---
description: "The canonical local stack: Postgres, Redis, schema migration, core API, worker and web console."
---

# 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](https://getmarula.com/docs/psa/v0.3/how-to/upgrade)
and the [backup/restore runbook](https://getmarula.com/docs/psa/v0.3/reference/backup-restore).

> [!NOTE]
> The compose file is the ground truth. This page maps the services and ports;
> if the two disagree, the compose file wins.

## 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](https://getmarula.com/docs/psa/v0.3/how-to/local-demo-stack)
walks it, including the desktop alternative that needs no Docker.

To run every service in containers instead:

```sh
git clone https://github.com/Marlin-Hofmeyer/marula && cd marula
docker compose -f deploy/docker-compose.yml up --build
```

Migrations 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 stage2`** adds object storage (Garage) and the Mailpit email
  sink. Default object storage is the local-fs none mode, so plain `up`
  needs no Garage; flip `OBJECT_STORE=s3` when you enable the profile.
- **`--profile observability`** adds the OTel collector, Tempo, Loki,
  Grafana. Telemetry export is disabled by default: it activates only when
  `OTEL_EXPORTER_OTLP_ENDPOINT` is 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.
