A web app and a database
Two services, then a third shared across every branch. This is where scope and depends_on start to matter.
Follows on from A preview per branch.
Two services
[project]
name = "myapp"
[runtime]
default = "docker"
[services.web]
image = "node:22"
port = 3000
command = "npm run dev"
depends_on = ["api"]
[services.api]
image = "node:22"
port = 8080
command = "npm run api"
health = "http://localhost:8080/healthz"$ minato up
✓ starting api
✓ waiting for api
✓ starting web
✓ waiting for web
╭ myapp / (main) ───────────────────────────╮
│ main /path/to/myapp │
│ │
│ ● web ready https://web.myapp.localhost │
│ ● api ready https://api.myapp.localhost │
╰───────────────────────────────────────────╯depends_on put api first. Both got their own URL.
Letting the frontend find the API
The API's URL is different on every branch, so it cannot be hardcoded. Minato injects it:
const api = process.env.MINATO_URL_API // https://api.feature-x.myapp.localhost$ minato exec web -- printenv MINATO_URL_API
https://api.myapp.localhostEvery service gets MINATO_URL_<SERVICE> for every other service. This is the piece that makes per-worktree environments work at all — without it, the frontend would have to guess.
For server-to-server calls inside the same workspace you can also use the service name directly on Docker (http://api:8080), which skips the proxy. That does not work on Apple Container; see Runtimes.
Adding a database
[services.db]
image = "postgres:16"
port = 5432
scope = "project"
expose = false
volumes = ["pgdata:/var/lib/postgresql/data"]
env = { POSTGRES_PASSWORD = "postgres", POSTGRES_DB = "myapp" }
[services.api]
image = "node:22"
port = 8080
command = "npm run api"
depends_on = ["db"]Three decisions in there:
scope = "project" — one database for every worktree, rather than one each. You seed it once, and branches see the same data.
expose = false — no URL, no route. The database is reachable from other services and from nowhere else. Always set this on a database.
volumes — named storage so the data survives down and up. Because it is named rather than a host path, the runtime manages it and it is scoped to the project.
$ minato up
✓ starting db
✓ starting api
✓ starting web
╭ myapp / (main) ───────────────────────────╮
│ main /path/to/myapp │
│ │
│ ● web ready https://web.myapp.localhost │
│ ● api ready https://api.myapp.localhost │
│ ● db ready internal only │
╰───────────────────────────────────────────╯(internal only) is expose = false doing its job.
Confirming the sharing
$ minato new feature/reports
$ cd ../myapp.wt/feature-reports
$ minato status
╭ myapp / feature-reports ──────────────────────────────────╮
│ feature/reports /path/to/myapp.wt/feature-reports │
│ │
│ ● web ready https://web.feature-reports.myapp.localhost │
│ ● api ready https://api.feature-reports.myapp.localhost │
│ ● db ready internal only │
╰───────────────────────────────────────────────────────────╯New web and api; the same db:
$ docker ps --filter label=dev.minato.project=myapp --format '{{.Names}}'
minato-myapp-feature-reports-web
minato-myapp-feature-reports-api
minato-myapp-main-web
minato-myapp-main-api
minato-myapp-shared-dbminato-myapp-shared-db — one, not one per worktree. Write a row from one branch and the other sees it.
When sharing is wrong
Two branches with incompatible migrations against one database will fight. Minato does not solve this. When it applies:
[services.db]
scope = "workspace" # one eachYou pay the seeding cost and get independence. Decide per project, and expect to change your mind on the branch that adds a migration.
Connecting to it
[services.api]
env = { DATABASE_URL = "postgres://postgres:postgres@db:5432/myapp" }db:5432 resolves on Docker. Better, keep the password out of the repository:
$ minato env set DATABASE_PASSWORD='op://Development/myapp/db' --scope projectThat is a reference, not a value. It is resolved when the container starts and never written to disk. See Environment variables.
Idle timeouts with several services
Only requests through the proxy count as activity. A database that only the API talks to looks idle even while the API is busy, and will stop.
[services.db]
idle_timeout = "8h"It will still be woken by whatever needs it, but this avoids paying the restart repeatedly during a working day.
Next
- Sharing a preview — put a branch on the internet
- Configuration — the rest of the keys