# Production self-hosting

The checked-in Docker path runs the Node Runtime against PostgreSQL with managed authentication. It is intentionally a small single-host baseline: PostgreSQL is private to the Compose network, the Runtime is non-root and read-only, and the published API port binds only to host loopback. Put a TLS reverse proxy or private ingress in front of it before accepting remote traffic.

## Choose the correct deployment surface

| Surface                       | Intended use                                                | Production contract                                                                                             |
| ----------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| SQLite source/Windows Runtime | Local evaluation, development, and single-node storage      | Full Store Contract v20; SQLite Webhooks are limited to one Runtime process                                     |
| Node Runtime + PostgreSQL     | Shared and production self-hosting                          | Full daemon API, managed authentication, Store Contract v20, migrations, evidence, replay, and release controls |
| Cloudflare Worker + D1        | Small single-Workspace CRUD and Context preview experiments | Not the daemon API or Store Contract v20; do not use it as the production control plane                         |

The one-process limit is specific to SQLite Webhooks. Their lifecycle fencing is process-local, so a SQLite-backed Webhook deployment requires exactly one Runtime process; a Webhook deployment with multiple Runtime processes or hosts must use PostgreSQL. Independent root `MemoryStore` instances within one process share the coordinator when their database paths resolve to the same canonical path, while different SQLite files remain independent. Other SQLite capabilities retain their documented local multi-process behavior where stated.

The website is a separate static product surface. Building or starting these containers never publishes `index.html`, `styles.css`, or `script.js`.

## Prerequisites and secrets

Use Docker Engine with Compose v2. Create an ignored `.env.self-host` from `.env.self-host.example`, keep `MEMPLUMB_WORKSPACE=production` or choose the final Workspace name before creating any keys, then set a unique `POSTGRES_PASSWORD` of at least 16 characters. A 32-byte URL-safe random value is recommended:

```bash
openssl rand -hex 32
```

There is no sample or fallback database password. Compose interpolation and the image entrypoint both fail before startup when the secret is missing. The entrypoint safely URL-encodes the password while constructing the internal PostgreSQL URL. When running the image outside this bundled Compose topology against an external database, provide a complete `MEMPLUMB_DATABASE_URL` instead.

Provider, embedding, OTLP, and Webhook credentials belong in the deployment secret manager, not the Compose file or image.

## First boot

Start the database and build the Runtime image:

```bash
docker compose --env-file .env.self-host up -d --wait postgres
docker compose --env-file .env.self-host build --pull memplumb
```

`MEMPLUMB_WEBHOOK_MASTER_KEY` is optional until Webhooks are configured. After the image is built, generate it once, keep it in the deployment secret manager, and retain it across restarts:

```bash
docker compose --env-file .env.self-host run --rm memplumb \
  node bin/memplumb.js webhook-keygen --json
```

Losing or silently replacing that key makes existing endpoint signing secrets unavailable.

Before starting the API, create the first managed administrator key. Its `--workspace` value must exactly match `MEMPLUMB_WORKSPACE` in `.env.self-host`. The command below uses the example value `production` and initializes and migrates PostgreSQL under the same advisory migration lock used by Runtime startup:

```bash
docker compose --env-file .env.self-host run --rm memplumb \
  node bin/memplumb.js key-create \
  --store postgres \
  --workspace production \
  --name bootstrap-admin \
  --scope admin \
  --json
```

The response contains an `mp_live_...` token exactly once. Move it directly into the operator's secret manager; do not place it in `.env.self-host`, Compose arguments, images, or CI logs. Create narrower `memory:read`, `memory:write`, evaluation, and review keys for applications and workers, then revoke the bootstrap key when it is no longer required.

Start Runtime with that same Workspace and wait for readiness:

```bash
docker compose --env-file .env.self-host up -d --wait memplumb
curl --fail http://127.0.0.1:6060/ready
curl --fail http://127.0.0.1:6060/v1/session \
  -H "Authorization: Bearer ${MEMPLUMB_API_KEY}"
```

`/health` is a liveness view. `/ready` is the orchestration gate and fails when the active Store or required semantic profile is not ready. Both probes are unauthenticated and privacy-bounded; business endpoints remain authenticated. The MemoryOps console is available at `http://127.0.0.1:6060/console` and accepts the managed Bearer token.

## Network and process boundary

The CLI still defaults to `--host 127.0.0.1`. Startup fails closed when a non-loopback address is selected without either `--require-auth` or the legacy compatibility key. The image explicitly selects `--host 0.0.0.0` together with `--require-auth` inside its private network, while Compose publishes `127.0.0.1:6060` on the host. This prevents an accidental unauthenticated public listener when an operator runs the source CLI and keeps the self-hosted API behind the intended ingress.

For a reverse proxy on the host, forward to `127.0.0.1:6060` and terminate TLS there. For a reverse proxy in Compose, join it to the same network and forward to `memplumb:6060`; keep PostgreSQL unpublished. Configure request body limits, upstream timeouts, certificate rotation, and access logs so credentials and Memory content are not recorded.

The Runtime container runs as the upstream `node` user with a read-only root filesystem, a bounded `/tmp`, no Linux capabilities, and `no-new-privileges`. PostgreSQL data is the named `postgres-data` volume. These defaults reduce the container boundary but do not replace host patching, disk encryption, network policy, or database least privilege.

## Upgrades, migrations, and recovery

Runtime startup applies pending application migrations transactionally and serializes concurrent migrators with a PostgreSQL advisory lock. For controlled changes, inspect and migrate before replacing Runtime:

```bash
docker compose --env-file .env.self-host run --rm memplumb \
  node bin/memplumb.js schema-status --store postgres --json
docker compose --env-file .env.self-host run --rm memplumb \
  node bin/memplumb.js schema-migrate --store postgres --json
docker compose --env-file .env.self-host up -d --build --wait memplumb
```

Do not roll an older Runtime over a database after a newer schema has been applied. Review and deliberately update the digest-pinned Node and PostgreSQL base images as part of the same tested release; do not replace them with floating tags. Create and verify a signed logical Workspace Snapshot before upgrades, and operate encrypted PostgreSQL physical backups with restore drills and retention controls. See [Disaster recovery](./disaster-recovery.md) for the application-level export, purge, and restore boundary.

The Compose sample is deliberately one Runtime replica. PostgreSQL supports shared quotas and multi-process Store semantics, but production replica counts, ingress, secret injection, observability, backup infrastructure, and rollout policy remain deployment-owner decisions. Validate those controls in staging before promotion.

## Acceptance gates

`scripts/verify-self-hosting.js` statically checks the fail-closed and least-privilege Compose contract. `scripts/container-smoke.js` builds the real image, starts PostgreSQL, waits on `/ready`, verifies unauthenticated rejection, creates a managed key, authenticates, and writes one Event. CI runs both and always removes its test volume.

Run the static checks without a Docker daemon:

```bash
node scripts/verify-self-hosting.js
POSTGRES_PASSWORD=local-check-0123456789abcdef \
  docker compose --env-file .env.self-host config --quiet
```

Run the destructive ephemeral smoke only on a Docker host where temporary images, containers, and volumes are acceptable:

```bash
node scripts/container-smoke.js
```
