Back up and restore
This recipe covers what actually needs backing up on a Meridian instance and the restore sequence to bring it back. For why the durable state lives in Restate rather than in application code, see explanation/why-durable-execution.md; for the process/volume layout referenced below, see reference/topology.md.
What to back up
| Data | Where | Criticality |
|---|---|---|
| Durable run state (journal, suspended runs) | restate-data volume |
Critical — this is the only copy |
| Run index (id, workflow, patient, event, date) | proxy-data volume (SQLite) or Postgres |
Convenience only — rebuildable |
| Workflows, agents, instance manifests | YAML files in git | Already backed up (the repo is the backup) |
| Patient record | The hospital's own FHIR warehouse | Out of platform scope |
The distinction that matters: Restate's journal is the only place a run's
state actually lives. Losing the run index loses the list the API shows at
GET /api/runs, not any run's execution state — see
reference/run-store.md for what that index does
and does not hold. Losing restate-data loses in-flight and suspended runs
outright.
Nothing else needs a backup procedure:
- Workflow, agent, and instance-manifest YAML are versioned in git — the clone/checkout you deploy from is already the backup.
- The plugin cache (
MERIDIAN_PLUGIN_CACHE, default.data/plugins) only holds copies re-downloadable from the registry at next boot — see Set up a plugin registry. Losing it costs a re-download, not data. - The patient record lives in the hospital's FHIR warehouse, connected per Connect a FHIR warehouse — it is outside this platform's backup responsibility.
Back up Restate's durable state
Restate persists its journal to the path mounted at /restate-data, backed
by the restate-data volume in docker-compose.yml.
Snapshot that volume with Restate stopped, so nothing is mid-write:
1. Stop Restate
docker compose stop restate
Stopping only the restate service is enough — endpoint and proxy will
simply fail their calls to it until it comes back, they don't need to be
stopped to take a consistent snapshot of restate-data.
2. Snapshot the volume
docker run --rm \
-v restate-data:/data:ro \
-v "$(pwd)":/backup \
alpine tar czf /backup/restate-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /data .
Store the resulting archive wherever your other backups go (off-host, per your retention policy) — there is nothing Meridian-specific about handling this tarball.
3. Restart Restate
docker compose start restate
Back up the run index
SQLite (default)
The proxy's proxy-data volume holds the SQLite file at the container path
configured by RUN_DB_URL (default sqlite:/data/runs.sqlite in compose,
sqlite:.data/runs.sqlite for a bare process — see
apps/api/src/server/run-store.ts). Copy the file directly; a stopped or
running proxy can both be copied since SQLite runs in WAL mode, but stopping
the proxy first avoids capturing a WAL file mid-checkpoint:
docker compose stop proxy
docker run --rm -v proxy-data:/data:ro -v "$(pwd)":/backup \
alpine cp /data/runs.sqlite /backup/runs-$(date +%Y%m%d-%H%M%S).sqlite
docker compose start proxy
Postgres
If you've switched the run index to Postgres (see Use Postgres for the run index), back it up the same way as any other Postgres database in your stack — this index is convenience data, not the source of truth, so an occasional dump is enough:
pg_dump "$RUN_DB_URL" -f runs-$(date +%Y%m%d-%H%M%S).sql
Restore
Restore order matters: Restate first (it's the source of truth), then the processes that depend on it, then the run index — which, if lost, just starts empty rather than blocking anything.
1. Restore Restate
docker compose stop restate
docker run --rm -v restate-data:/data -v "$(pwd)":/backup alpine sh -c \
"rm -rf /data/* && tar xzf /backup/restate-data-<timestamp>.tar.gz -C /data"
docker compose start restate
Keep RESTATE_NODE_NAME identical to what it was when the volume was
written. Restate otherwise derives its node name from the container's
hostname; a new container gets a new hostname and refuses to start against
the restored data (error RT0002) — this is exactly why
docker-compose.yml pins
RESTATE_NODE_NAME: restate-dev for the dev service instead of leaving it to
default. Pin the same fixed name in whatever compose file or manifest you
restore into.
2. Restart the endpoint
docker compose restart endpoint
The endpoint self-registers with Restate's admin API on startup with retries
(apps/api/src/restate/register.ts, autoRegister) — no manual
re-registration step is needed. Restate resumes any run left suspended or
in-flight in the restored journal from wherever it was left off; this is the
same guarantee an ordinary endpoint restart relies on, see
Upgrade the platform.
3. Restart the proxy and UI
docker compose restart proxy ui
4. Restore the run index (optional)
Only do this if you want old entries back in GET /api/runs — it does not
affect any run's actual state:
# SQLite
docker compose stop proxy
docker run --rm -v proxy-data:/data -v "$(pwd)":/backup \
alpine cp /backup/runs-<timestamp>.sqlite /data/runs.sqlite
docker compose start proxy
# Postgres
psql "$RUN_DB_URL" -f runs-<timestamp>.sql
Verify
Confirm the stack is healthy and the restored journal is actually being served:
curl http://localhost:4000/readyz
/readyz returns 200 only once both the Restate ingress and the run index
respond (apps/api/src/server/api.ts); on failure it returns 503 with a
{ checks } detail naming which one is down. Then confirm a run that existed
before the backup is still resolvable:
curl http://localhost:8080/meridianWorkflow/<eventId>/getRun
Related
- reference/run-store.md — what the run index contains and why it's separate from Restate's state.
- reference/topology.md — the four processes and their volumes.
- explanation/why-durable-execution.md — why the journal, not application code, is the source of truth.
- Use Postgres for the run index — switching backends before you need to scale the proxy.
- Upgrade the platform — the restart order for a routine version upgrade, as opposed to a disaster restore.