Skip to content

Deployment

This guide takes Marina from a local bun run start to a running deployment on AWS or any other cloud, with TLS, persistence, and backups. It assumes you’ve read Getting Started and Configuration.

Marina is a single process backed by a single SQLite file. There is one writer. You scale it vertically (a bigger box), not horizontally (more replicas). Do not run two instances against the same database file — you will corrupt it.

This is a deliberate design choice: the first target is a personal computer, and SQLite makes the world database portable; complete recovery also requires the instance files and optional auth database. For the vast majority of deployments — a team, a demo, a research instance, even a public endpoint — one well-provisioned instance is the right answer. If you genuinely outgrow it, the path is federation (many independent instances bridged together), not a shared database.

Everything below follows from this: pick a single durable volume, put the database on it, run one container, and back it up.

Marina needs no particular cloud: the setups below run anywhere Docker or Bun runs. The repository carries no production deployment workflow; the maintainers deploy their own instance from outside it. Continuous deployment below describes the pattern to follow for yours.

scripts/deploy.sh waits for container health, then checks the running HTTP service and configured providers with scripts/smoke-production.ts. A failing smoke check makes the deployment fail; it does not automatically roll back the database or image. The full report stays at /tmp/marina-production-smoke.json inside the container. See Operating a Marina for the probe’s scope and authentication settings.

A single Bun process serves everything on WS_PORT (default 3300):

Surface Path / Protocol Notes
Web chat GET /chat Compact browser client (GET / redirects to the dashboard)
Dashboard SPA /dashboard, /canvas, /who/<name> Built from dashboard/ into dist/dashboard
WebSocket /ws Live agent/human connection
OpenAI/Ollama API /v1/*, /api/* Drop-in LLM endpoint (Model API)
Memory API /mem REST notes/recall (Memory API)
Health GET /health Returns JSON {status:"ok", uptime, connections, ...}

Three more ports run alongside it:

Port Env var Purpose Expose publicly?
3300 WS_PORT HTTP + WebSocket + API (above) Yes (behind TLS)
4000 TELNET_PORT Plain-text telnet client (off by default; set to enable) No — internal/admin only
3301 MCP_PORT MCP server for tool clients Only if you use MCP remotely
3302 LOG_PORT Real-time event log viewer No — internal only

Persistent state is just two things, both under /app/data in the container image:

  • DB_PATH — the SQLite database (default /app/data/marina.db), plus its -wal/-shm sidecars in WAL mode.
  • ASSETS_DIR — uploaded canvas assets (default /app/data/assets).

Put a single durable volume at /app/data and your entire world persists across restarts and redeploys.

  • Bun ≥ 1.4.4 if running outside Docker. Install via the official installer (curl -fsSL https://bun.sh/install | bash) — some distro-packaged Bun 1.3.x builds have a broken Date.now(); scripts/build.sh checks for this.
  • Docker (with the Compose plugin) for the containerized path below.
  • At least one LLM provider key (e.g. ANTHROPIC_API_KEY) if you want agents to think. Without any key, rooms fall back to static entities and the world still runs.

The repo ships a multi-stage Dockerfile and a docker-compose.yml. From a clean checkout:

Terminal window
cp .env.example .env

Uncomment the keys and secrets you need in .env (see Security below), then build and start:

Terminal window
docker compose up -d --build
docker compose logs -f

Then open http://localhost:3300. State lives in the named volume marina-data; docker compose down stops the instance without deleting it, and docker compose down -v wipes the world.

The image builds the dashboard SPA, runs as an unprivileged bun user, and ships a HEALTHCHECK that polls /health. docker compose up -d returns once the containers start, without waiting for that check; add --wait (docker compose up -d --build --wait) to block until it reports healthy.

Inside the container Marina binds 0.0.0.0, so it derives the public trust profile: the OpenAI-compatible API stays closed until you set MODEL_API_KEYS, safety gates are enforced, and seeded agents do not start on their own. That is the right default for a server. For a personal instance on your own machine, add the local overlay, which declares MARINA_PROFILE=local and so behaves like a native bun run start (a generated model-API key, kept in a 0600 file next to the database in the data volume and never written to the logs — docker compose logs shows only the $(cat …) line that reads it; seeded agents start once a provider key is set, $50/day default spend cap):

Terminal window
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build

The overlay is safe only because docker-compose.yml publishes the ports on the host’s 127.0.0.1. Never use it with widened ports:, a reverse proxy in front of the host, or the EC2/server deploy (scripts/deploy.sh does not use it). Boot enforces this: docker-compose.yml passes the publish address into the container as MARINA_PUBLISHED_HOST, and with a non-loopback MARINA_BIND_IP the local profile is a fatal startup error.

Under the local profile you, the operator, are ungated. An agent’s host shell (run raw, shell) and arbitrary non-allowlisted host commands wait for your approval (challenge approve <token>, or code exec-approve in Code Mode); MARINA_AUTONOMY=sovereign lifts that hold as well.

Publishing beyond loopback (MARINA_BIND_IP)

Section titled “Publishing beyond loopback (MARINA_BIND_IP)”

docker-compose.yml publishes every port on 127.0.0.1 by default, so a fresh docker compose up is reachable from that machine only. A reverse proxy or load balancer on another host connects to this host’s routable address, not its loopback — so with the default it cannot connect at all and every health check fails. Set the host interface to publish on:

# in .env — 0.0.0.0 for every interface, or pin a specific host IP
MARINA_BIND_IP=0.0.0.0

Before widening the bind, enable sign-in (MARINA_AUTH=better-auth), set MODEL_API_KEYS, and keep the host firewall / security group scoped to the proxy or load balancer. Publishing on 0.0.0.0 makes the port reachable from anything that can route to the host; the network boundary is then your security group, not Docker.

Compose passes the same address into the container as MARINA_PUBLISHED_HOST. Marina binds 0.0.0.0 inside the container either way, and its startup check treats that bind as loopback only when the published host is loopback (and Marina is running in a container). A routable MARINA_BIND_IP with passwordless login is therefore refused at boot until you enable MARINA_AUTH=better-auth. If you knowingly run a passwordless instance on a private network, set MARINA_ALLOW_INSECURE_PUBLIC=true in .env; Compose no longer sets it for you.

scripts/deploy.sh runs bun run src/main.ts --check-config in the new image before it restarts the stack: every boot refusal runs against this host’s configuration, and a refused configuration stops the deploy with the running stack unchanged. Run the same command yourself to check a .env change.

Terminal window
docker build -t marina .
docker run -d --name marina \
-p 127.0.0.1:3300:3300 \
-v marina-data:/app/data \
--env-file .env \
-e MARINA_PUBLISHED_HOST=127.0.0.1 \
marina

The 127.0.0.1: publish prefix keeps the instance host-local; MARINA_PUBLISHED_HOST tells Marina so (the container-internal bind is 0.0.0.0 so the mapping works at all). Keep the two in step. Widening to -p 3300:3300 exposes passwordless login to the network: enable MARINA_AUTH=better-auth first.

All variables are optional with sane defaults. .env.example is a short starter; the complete annotated catalog is config/environment.reference (rendered as the environment reference). The load-bearing ones for a deployment:

MARINA_WORLD=default # which world to load
DB_PATH=/app/data/marina.db # keep on the durable volume
ASSETS_DIR=/app/data/assets # ditto
MARINA_NAME=my-instance # shown in the dashboard topbar
LOG_FORMAT=json # structured logs for your aggregator
MARINA_LOG_RETENTION=10000 # durable structured-log rows retained in SQLite
ANTHROPIC_API_KEY=sk-ant-... # (or OPENAI_API_KEY, GEMINI_API_KEY, ...)

Security checklist (do this before exposing to the internet)

Section titled “Security checklist (do this before exposing to the internet)”

Marina’s HTTP API requires authentication by default — but it’s easy to weaken it, so verify:

  • Set MODEL_API_KEYS (comma-separated bearer tokens) for the /v1/* and /api/* LLM endpoints. Clients send Authorization: Bearer <token>.
  • Set MEM_API_KEYS (comma-separated secret:agent pairs) if you expose the /mem Memory API.
  • Never set MARINA_OPEN_API=true on a public host — it disables API auth entirely. It’s a local-dev convenience only.
  • Enable dashboard sign-in with MARINA_AUTH=better-auth (+ BETTER_AUTH_SECRET) for any human-facing public host — see authentication.md. Without it the dashboard is open to anyone who can reach it.
  • Encrypt API keys at rest. Provider keys saved in the Admin → Keys panel are AES-256-GCM encrypted in the DB. Without MARINA_KEY_SECRET, Marina generates the secret itself in <DB_PATH>.key-secret (mode 0600) and encrypts keys saved from then on (keys stored as plaintext before keep working until you re-save them or set an explicit secret). For a managed deployment set MARINA_KEY_SECRET (≥ 16 chars; openssl rand -base64 32) from your secret manager — an explicit secret also encrypts existing plaintext rows in place — or prefer the provider env vars (ANTHROPIC_API_KEY, …, LLAMA_API_KEY), which are read live and never persisted. Admin → Security shows the live state. (Back up the secret or the .key-secret file with the database — losing it orphans stored keys.)
  • Set ALLOWED_ORIGINS to your real dashboard origin(s) if clients run cross-origin. Unset = same-origin only (no CORS header), which is the safe default.
  • Don’t publish ports 4000 (telnet) and 3302 (log viewer) — neither is authenticated. Telnet is off by default (TELNET_PORT=0), and both listeners bind the resolved WS_HOST (loopback unless you opt into exposure), but the publish spec is still your boundary: keep them off your public load balancer / security group. The default docker-compose publishes all ports to the host’s 127.0.0.1 only.
  • Set GATEWAY_SECRET if (and only if) you use federation. Otherwise leave it unset.
  • Terminate TLS at a reverse proxy (next section). Marina speaks plain HTTP/WS; never expose 3300 directly to the internet.
  • Rate limits are built in (WebSocket, telnet and MCP commands 50/s with a burst of 200 per entity — one limiter, MCP in its own bucket — Model API 20/s burst 100 per IP, Memory API 10/s burst 20 per agent, dashboard REST 60/10 s per principal, canvas + asset writes 30/10 s per principal, public /api/entity/* 30/10 s per IP, MCP sessions 10/min per IP, pre-auth /api/setup-status 20/min per IP, model-API reads 120/min per IP, public asset reads 300/min per IP, and 20 failed /v1 / /mem credentials per IP per minute before that address is refused) but a proxy-level limit is still wise. Per-IP limits key on the TCP peer; set MARINA_TRUST_PROXY=true behind your reverse proxy so they key on X-Forwarded-For instead.
  • Public hostname on a loopback bind (a reverse proxy on the same host forwarding to 127.0.0.1:3300): set MARINA_ALLOWED_HOSTS=marina.example.com — a loopback bind answers only to loopback Host names and the names listed there (DNS-rebinding guard), so without it every proxied request gets 421. On a non-loopback bind, setting it turns the same check on. See security.
  • MCP behind a public hostname: set MARINA_MCP_ALLOWED_HOSTS=mcp.example.com (DNS-rebinding guard; unset, a public bind accepts only loopback names, the bind address, the machine hostname and the BETTER_AUTH_URL/ALLOWED_ORIGINS hosts) — the transport already requires a MODEL_API_KEYS bearer on any non-loopback bind. See MCP transport security.
  • Request bodies are capped at 8 MiB (MARINA_MAX_REQUEST_BODY_BYTES); asset uploads at 50 MiB (MARINA_MAX_UPLOAD_BYTES). Uploaded assets are MIME-allowlisted and served with nosniff + a no-script CSP; every HTML page gets X-Frame-Options: SAMEORIGIN and the dashboard Content-Security-Policy below.

Every HTML route (/dashboard, /canvas, /who/*, /chat, /ask, the “dashboard not built” placeholder) is served with this header (HTML_CSP in src/net/http-utils.ts):

default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob:; media-src 'self' blob: data:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ws: wss:; worker-src 'self' blob:; frame-src 'self' https:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'

The built dashboard loads one same-origin module script and no inline scripts, so script-src 'self' is enforced — an injected <script> or javascript: URL does not run. The grants beyond 'self' are the ones the bundle genuinely uses: inline style= attributes (motion, React Flow), Google Fonts (the display/mono faces), blob:/data: object URLs for previews, WebSockets, and https: iframes for the embed canvas node / asset kind (sandboxed). /chat and /ask carry inline scripts and receive the same policy with their scripts’ 'sha256-…' digests appended — never 'unsafe-inline'.

  • MARINA_DASHBOARD_CSP=off removes the header (framing protection via X-Frame-Options stays). Use only while diagnosing a blocked resource.
  • MARINA_DASHBOARD_CSP="<policy>" replaces it verbatim on every HTML route — e.g. to add the origin of an external asset store to img-src/media-src, or to drop the Google Fonts grants once you self-host them. A custom policy gets no inline-script hashes appended: include them (or 'unsafe-inline' for script-src) yourself if /chat and /ask stay reachable, or keep only the SPA routes behind it.
  • Blocked loads show up in the browser console as Refused to load … because it violates the following Content Security Policy directive. Report the resource and directive rather than switching the header off in production.

Put a proxy in front of WS_PORT. It must forward WebSocket upgrade headers (for /ws and the dashboard’s live feed). Caddy does this with automatic HTTPS in two lines:

marina.example.com {
reverse_proxy localhost:3300
}

Caddy forwards the client’s Host (marina.example.com). When Marina binds loopback (the default outside Docker), list that name: MARINA_ALLOWED_HOSTS=marina.example.com; otherwise Marina answers 421 Misdirected Request. Alternatively have the proxy send a loopback Host (header_up Host {upstream_hostport} in Caddy, proxy_set_header Host localhost; in nginx).

Caddy proxies WebSockets transparently. The equivalent nginx location / needs proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade";. Behind an AWS ALB, enable the WebSocket-compatible defaults and a generous idle timeout (the WS server caps idle at ~255s; set the ALB idle timeout to 300s+).

The world database, optional auth database, assets, workspaces and configuration form the recoverable instance. A database-only online snapshot is useful independently. Take a consistent, WAL-safe and verified database snapshot:

Terminal window
docker compose exec marina ./scripts/backup.sh /app/data/marina.db /app/data/backups

scripts/backup.sh uses the verified SQLite snapshot implementation (VACUUM INTO, integrity/foreign-key checks, hash, restricted permissions). scripts/restore.sh BACKUP NEW_DB refuses existing destinations. For complete instance recovery use the offline recovery bundle. The JSON export/import scripts move selected logical tables; they do not include binary assets, workspaces or all credentials, and their output can still contain private content.

Recommended: a cron/systemd timer (or an ECS scheduled task) that runs the backup and ships the result to S3. Snapshotting the underlying volume (EBS/EFS snapshot) also works as long as you snapshot the whole /app/data directory.

Note: admin DB snapshots (admin snapshot <name>) write to /app/seeds inside the container, which is not on the volume — they’re lost on redeploy. Use the backup/export scripts (which target /app/data) for anything you need to keep.

Section titled “A. Single VM with Docker Compose (recommended baseline)”

The simplest production-grade setup, and the one that best fits the single-writer model. Works on an AWS EC2 instance, Lightsail VM, DigitalOcean droplet, GCP/Azure VM — anything that runs Docker.

  1. Provision a small VM (a 2 vCPU / 4 GB box handles a busy instance; agents are I/O- and API-bound, not CPU-bound). Attach a persistent disk.
  2. Install Docker + the Compose plugin.
  3. Clone the repo, create .env, point the marina-data volume at the persistent disk (e.g. bind-mount /mnt/data:/app/data instead of the named volume).
  4. docker compose up -d --build.
  5. Run Caddy (or your proxy) on the same box for TLS, pointing at localhost:3300.
  6. Add a daily backup timer that runs scripts/backup.sh and aws s3 cp the result.

This keeps the database on a single fast local/EBS disk (ideal for SQLite) and the whole instance is one docker compose pull && up -d away from an upgrade.

Managed containers, no VM to patch. The trick is keeping it to exactly one task with the database on durable shared storage.

  • Image: build and push to ECR (docker build -t <ecr>/marina . && docker push ...).
  • Storage: create an EFS file system, mount it into the task at /app/data via an EFS volume + mount point. (EFS, not ephemeral task storage — the latter vanishes when the task recycles.)
  • Service:
    • desiredCount: 1.
    • Deployment config minimumHealthyPercent: 0, maximumPercent: 100 — this stops the old task before starting the new one, so two tasks never touch the SQLite file at once. (The usual rolling default would briefly run two writers — don’t use it here.)
    • Health check: container HEALTHCHECK already polls /health; also point the ALB target group health check at /health.
  • Networking: an ALB in front of the 3300 target group, TLS cert via ACM, WebSocket-friendly (ALB supports WS natively; bump idle timeout to ≥300s). Do not add target groups for 4000/3302.
  • Secrets: put MODEL_API_KEYS, provider keys, etc. in AWS Secrets Manager / SSM Parameter Store and inject as task secrets.

EFS works fine for a single-writer SQLite file, but it has higher latency than EBS. If write latency matters, prefer setup A (EC2 + EBS) — local block storage is the happiest home for SQLite.

The middle ground — managed containers without ECS’s complexity, with built-in TLS.

  • Push the image to a Lightsail container service (or ECR).
  • Deploy with the public endpoint pointed at port 3300 and the health check path set to /health.
  • Caveat: Lightsail container services have no persistent volume. Use this only for ephemeral/demo instances, or point DB_PATH at an external store you control. For durable Lightsail, use a Lightsail VM with an attached disk and setup A instead.

Fly maps cleanly onto the model: one machine, one volume.

fly.toml
app = "marina"
[build]
dockerfile = "Dockerfile"
[env]
DB_PATH = "/app/data/marina.db"
ASSETS_DIR = "/app/data/assets"
MARINA_WORLD = "default"
[[mounts]]
source = "marina_data"
destination = "/app/data"
[http_service]
internal_port = 3300
force_https = true
auto_stop_machines = false # keep the single writer alive
[[http_service.checks]]
path = "/health"
Terminal window
fly volumes create marina_data --size 10
fly secrets set MODEL_API_KEYS=sk-... ANTHROPIC_API_KEY=sk-ant-...
fly deploy

Keep auto_stop_machines = false and min_machines_running = 1 — and do not scale count above 1. The same pattern applies to Render, Railway, and Koyeb: one instance, one attached disk at /app/data, health check on /health, secrets for keys.

  • Don’t run replicas against one DB. Two writers corrupt SQLite. Scale up the box, not out.
  • Vertical headroom: in-memory entity storage is fine into the thousands of entities; a single SQLite writer comfortably handles a busy instance. Most load is outbound LLM API calls, so size for memory and network, not CPU.
  • Need multiple regions or teams? Run independent instances and bridge them with federation and GATEWAY_SECRET — each keeps its own database.
  • Cost control: set MARINA_ROOM_AGENTS=false to stop rooms from auto-spawning LLM-connected agents, or omit provider keys entirely for a static world.
  • Logs: set LOG_FORMAT=json and collect stdout with your aggregator (CloudWatch, Loki, etc.).
  • Health: curl https://your-host/health returns 200 + JSON when live; this is what the container and load balancer probes use.
  • Upgrades: git pull (or pull a new image tag), then docker compose up -d --build. Back up first; migrations (src/persistence/schema.ts) run automatically on boot and are append-only. See Upgrading.
  • Stuck? See Troubleshooting.

The repository is a single Bun workspace. One bun install at the root resolves every member from the one bun.lock; the members have no lockfiles of their own.

Member Path What it is
marina / the server, CLI, SDK build, tests
marina-dashboard dashboard/ the React dashboard SPA (built into dist/dashboard)
marina-site site/ the Astro + Starlight documentation site
marina-desktop marina-desktop/ the Electrobun desktop shell
marina-usecase-ui examples/usecase-ui/ example React front end
marina-coding-agent-demo examples/coding-agent-demo/ Code Mode demo fixture (its failing test is deliberate)
@marina/agent-sdk src/sdk/ the TypeScript SDK manifest

Two packages are deliberately not workspace members and keep their own bun.lock: extensions/local-embeddings (onnxruntime-node + MiniLM — a large native dependency that must not enter the standard install) and extensions/langgraph-store (pulls LangGraph). Both are published with their lockfiles via the root files list and installed on demand with bun install --cwd extensions/<name> --frozen-lockfile.

Bun uses the isolated linker for workspaces: each member’s node_modules/ holds symlinks into the shared node_modules/.bun/ store, and only packages a member declares are resolvable from it. A bare import of a transitive dependency (“phantom dependency”) fails at build or run time — declare it in that member’s package.json instead. Two root-only tables apply to the whole graph regardless of which member pulls the package in: overrides and patchedDependencies (Bun ignores both in member manifests; check:overrides lists any it finds there as IGNORED).

bun run dashboard:build emits dist/dashboard, bun run build:memory emits dist/memory.js, and prepack runs both so the npm tarball includes them.

Every versioned package.json in the repository (workspace member or not, node_modules excluded) must carry the root version. The script prints one row per manifest and exits 1 on a mismatch; CI runs it in the backend job before the tests, and qualify:release runs it first. Bump every manifest together when cutting a release.

Audits the root overrides table against the dependency graph. For each override it reads every dependent’s declared range from bun.lock, asks the registry (bun info <pkg> versions) which version each range would pick today without the override, and reports:

  • still needed — some dependent would land below the floor (or, for an exact-version pin, the dependents would otherwise split across several versions);
  • no longer needed — every dependent already lands at or above the floor on its own;
  • unknown — registry unreachable and the lockfile alone cannot decide (--offline forces this mode).

The note lines flag the harmful case: a floor whose range excludes a version a dependent explicitly declares (a ^6 floor forcing a ^8 dependent down to 6.x). The documented column cross-references the rationale table below (plus SECURITY.md, README.md, and the rest of docs/) — an override with no written reason shows NO. --strict exits 1 on any no longer needed; --json emits the full report. The nightly workflow prints the table non-blocking; run bun run check:overrides --strict before removing or adding an override.

Why each override exists (keep this table in sync with package.json):

Override Kind Reason
qs ^6.16.1 floor Express 5 transitive security advisories (GHSA-6pq8-jhm2-2vcw covers ≤ 6.16.0); typed-rest-client pins an older qs, so the floor forces the patched version.
fast-uri ^3.1.9 floor URI scheme spoofing and host confusion advisories (fixed in 3.1.9), reached through ajv from @modelcontextprotocol/sdk and @stryker-mutator/core.
minimatch ^11.0.3 floor GHSA-g3hv-75wg-m249 (denial of service via globparts amplification, fixed in 11.0.1); @stryker-mutator/core declares ~10.2.4, so the floor forces the patched major (the CLI loads and runs on it).
postcss-selector-parser ^7.1.6 floor GHSA-rj75-hqrm-r3gf (CPU exhaustion on flat selectors). Expressive Code still pulls parser 6.x through postcss-nested 6.x; the site build qualifies compatibility with the patched 7.x parser. Remove when that dependency chain accepts the fix.

A floor stays only while check:overrides --strict reports it still needed; once every dependent resolves to the patched version on its own, remove it. Re-add a floor here, with its advisory, if an audit finds a regression.

scripts/deploy.sh is the host-side half of an example pipeline to adapt to your own infrastructure, not a requirement. Your CI runs it on the server (for example over SSH or a cloud run-command service) after checking out the revision it deploys. The pattern:

  • Build the image on each change to your release branch and tag it with the commit SHA (immutable), alongside any moving tag such as latest.
  • Deploy by pinning that SHA tag (MARINA_IMAGE=<registry>/<repo>:sha-<commit>) and running docker compose up -d.
  • Roll back by redeploying an older SHA tag; no rebuild is needed.
  • Keep host-specific configuration in the untracked .env and persistent data in the data volume (MARINA_DATA_VOLUME, default marina-data) so both survive redeploys.