Local development
Start Postgres and the Service Bus emulator, pick processes with ./localenv, and run Go services natively.
Stateful dependencies run in Docker Compose: Postgres, the Service Bus emulator, Azurite,
and Ory (Kratos and Hydra) for authentication. Go services run on the host through
process-compose, orchestrated by ./localenv.
Prerequisites
./localenv setup refuses to run without these — it prints the whole list with versions
first, so a missing one is named up front rather than surfacing later as a failure inside
go run or pnpm install.
- Go 1.26+ — every service, and
./localenvbuilds its own CLI from source, so Go comes before anything else - Docker, with the Compose plugin — Postgres, the Service Bus emulator, Azurite, Ory, and the Bruno CLI. Installed is enough for setup; it has to be running before
up. Docker Desktop or Rancher Desktop both work - Node 26.4+ — every app under
frontend/. The floor is the strictestengines.nodeof the three apps, not a number written down separately - pnpm —
corepack enable pnpm; corepack resolves the version each app pins - git, make, curl — the repository, the local CI checks, and calling the services by hand
Homebrew, Bruno, az, helm, helm-secrets, lsof, sops, psql, and jq are
reported but never block: each buys one command rather than the stack.
./localenv setup installs Bruno, Azure CLI, Helm, and helm-secrets on macOS when
Homebrew is there, and puts the Bruno collection in its sidebar. Helm 4 needs the
helm-secrets release .tgz packages — installing from the GitHub repo URL leaves
helm secrets as an unknown command. Decrypt also needs
az login --scope https://vault.azure.net/.default and the repository-root
.sops.yaml. The install commands are in
docs/local-dev.md.
On Apple Silicon, enable Rosetta on the Docker engine (Docker Desktop, Rancher
Desktop, or Colima). The Service Bus SQL image is linux/amd64; QEMU emulation
crashes it. ./localenv doctor and up check this when the daemon is running.
This does not apply with LOCAL_SERVICE_BUS=almost.
See docs/local-dev.md.
Local Service Bus broker
LOCAL_SERVICE_BUS in the repository root .env picks the local broker:
azure(default): Microsoft's Service Bus emulator and its SQL Server. It needsACCEPT_EULA=Yand accepts at most 50 queues and topics. Over that it exits withOut of memory.and code 139, so./localenv upwrites a config with only the entities the services you started use.almost: AlmostServiceBus, one in-memory container with no entity limit.cmd/sbprovisioncreates the entities after it starts. A container restart loses them until the next./localenv up. Dashboard at http://localhost:15672.
Run ./localenv down and then up after changing the value. The connection string is
the same for both. Details, including the ACR image, are in README.md
and docs/local-dev.md.
./localenv setup downloads process-compose itself. Run ./localenv doctor at any time
for the same list plus this checkout's state; what each optional tool is for is in
docs/local-dev.md.
Quick start
./localenv setup # once per machine; installs frontend dependencies
./localenv # picks processes and starts themStop the stack from another terminal:
./localenv downdown also frees the ports the stack binds. A closed terminal or a kill -9 leaves
process-compose's children running and holding their ports, which is why up used to
fail with EADDRINUSE on 3502 long after the stack looked stopped. The sweep only
kills processes whose working directory is inside the repository, and
./localenv doctor reports any port still held. See docs/local-dev.md.
Groups are generated from each service's Azure Pipelines chart list during setup
(for example ./localenv up --group template). Override or add groups in
.localenv/groups.yaml.
Machine-local generated files (emulator config, pipeline processes, remote env
overrides) live under .localenv/ and are not committed.
Development fixture startup and manual seeding are documented in docs/local-dev.md.
Database connections
Native Go services connect to Compose Postgres at 127.0.0.1:5433 as postgres,
using a database named after the service. Port 5432 is internal to the container.
localenv supplies DATABASE_URL to each service; a nonempty override in
services/<name>/.env takes precedence over the local default.
Process fragments store the resolved URL in vars.localenvDatabaseURL and set
DATABASE_URL through a template rendered after fragment merging. Keep this
indirection when adding startup dependencies: process-compose v1.120.0 otherwise
drops environment values containing another =, such as the URL's sslmode
parameter. The symptom is a connection attempt to a Unix socket on port 5432
using your operating-system username.
Local developer UI
local-app is ticked when the picker opens and serves two tools on http://localhost:3502:
| Tab | What it shows |
|---|---|
| Service Bus | Queue and subscription backlog for the local emulator |
| Database | SQL Manager against the Compose Postgres — schema tree, query editor, results, CSV / JSON export |
The database explorer connects as the postgres superuser and lists every service
database; pick one from the sidebar. See docs/local-dev.md for
the query limits and connection variables.
Frontends
The other apps are registered but unticked in the picker, so start them by name:
./localenv up docs-app # this portal, on :3501
./localenv up devops-app # DevOps console on :3500, with devops-api behind it
./localenv up intelligence-app # review console on :3503
./localenv up authn-app # sign-in screen on :3505, reading Ory Kratos on :4433Each app registers a process-compose.yaml next to its package.json, which is the
whole registration. ./localenv setup installs their dependencies; up refuses to
start an app that has none rather than leaving pnpm dev to fail in a restart loop.
Ports
tools/localenv/ports.yaml assigns every local port, one line per
process. ./localenv ports prints the table.
A number there is permanent, which is the point: it is what lets a .env file, a curl
script or a bookmark name a port once and keep working. Nothing is renumbered and nothing
is reused, so a new process takes the next free number at or above 8100 rather than the
lowest gap.
Everything else agrees with the registry or fails. ./localenv validate, which CI runs,
checks each process against it — including each frontend's own package.json dev script,
since pnpm reads that and not the fragment. The Makefile reads the file with sed, so
make dev-template and ./localenv up template-api cannot disagree.
./localenv add-service <name> claims the next free number for the API it scaffolds. A
service that gains a process from a new Helm chart needs one line by hand, and ./localenv
names the process and suggests the number.
Before the registry, the port came from the chart's role suffix alone, so every service's
API was assigned 8081 and the second one to start died with bind: address already in use.
The picker selection is the whole answer
Everything registered is on screen, so what you leave unticked does not start — including a
process that something you did tick depends on. Untick infra to run against Postgres and the
broker you already have up, and the services that depend on it start anyway rather than waiting
for a process that is never coming. up names each dependency it left out:
not starting infra: you did not select it (devops-api depends on it)When infra is ticked, nothing else starts until it is usable rather than merely started. Its
readiness probe runs pg_isready inside the Postgres container, asks the Service Bus emulator's
own health endpoint — which answers only once it has created the queues and topics from
Config.json — and makes a signed list-containers request to Azurite (an unsigned
list is a 403 on every probe). Host ports are deliberately not the test:
Docker binds 5433 and 10000 when the containers are created, well before Postgres finishes
recovery on a cold volume, so a port check calls the stack ready while it is not and every
service starts into a connection error.
Naming processes works the other way round. ./localenv up devops-app is a starting point rather
than a complete list, so it also starts devops-api and infra. --group and --all are the
same.
What the picker offers
A process is on the list when it is something you start: it declares a readiness probe, or it is
supervised with restart: on_failure and meant to stay up. The second case is the workers —
intelligence-batch, intelligence-enrichment, and the fanout listeners in workspace and
documents. They serve no HTTP of their own, so while the rule was the probe alone they were
registered and deployed but missing from the picker, and a local stack had nothing draining the
intelligence job queue.
What is left out is the one-shot steps: migrate-all, db-provision, infra-provision,
infra-preflight, and the seeds. They run because something else depends on them rather than
because anyone chose them.
./localenv up --show-all # put the helpers on the list too, dimmed
./localenv list --show-all # the same set as a table, with a KIND column--show-all decides what is on screen and nothing else. A helper appears dimmed and marked [-]
rather than with a tick box, and it still runs whenever something needs it. An empty tick box
beside migrate-all would read as a refusal, and a declined dependency is skipped rather than
waited for, which starts every service beside a Postgres that is still booting inside Docker.
Naming a helper works with or without the flag: ./localenv up migrate-all migrates a stack that
is already up.
./localenv list --names prints one name per line, which is what the shell completion reads.
Install it with eval "$(./localenv completion zsh)" to tab-complete process names as well as
flags.
Reading one process's log
./localenv attach opens the process-compose dashboard, which captures the mouse for its
own panes and so leaves the terminal unable to select text. To copy a stack trace, print the
log as ordinary output instead:
./localenv logs devops-api # last 200 lines
./localenv logs -f devops-api # follow
./localenv logs --all devops-api # the whole bufferDatabases
Every service that owns migrations gets its own database, named after the service, and every local
process connects to it as the postgres superuser. One account for everything is a local-only
simplification, and it is what makes adding a service need no database setup: db-provision creates
missing databases for the services in the resolved startup set on each ./localenv up, before
migrate-all runs migrations for those same services. Required dependencies are included; processes
left unticked in the picker are excluded. Starting only accounting does not migrate revenue.
Explicitly selecting migrate-all or db-provision requests all service databases; use
./localenv up migrate-all to migrate everything. An empty service scope migrates nothing.
If a migration fails,
./localenv prints the error and stops the stack — including infra — so a broken start cannot look
like "only Postgres came up". Fix the migration and run ./localenv again.
| Service | Database | Account |
|---|---|---|
| accounting | accounting | postgres / postgres |
| template | template | postgres / postgres |
| authn (Ory Hydra) | hydra | postgres / postgres |
| authn (Ory Kratos) | kratos | postgres / postgres |
The last two are not created by db-provision or migrated by migrate-all: Ory runs
its own migration container inside Docker Compose before each server starts, so the
hydra and kratos schemas are created and upgraded by Ory itself.
Deployed environments do not work this way. There, DATABASE_URL is a SOPS-encrypted secret with
a least-privilege account per service — see the service's .azure/env/readme.md.
Starting over
./localenv down
./localenv resetreset empties both halves of local state: the databases and the queues.
For databases it drops and recreates each service database and sweeps the test_* databases an
interrupted go test left behind. Dropping rather than truncating means a table or column
that a migration has since removed goes too, and it clears the outbox tables, so a message
written but never published does not come back. The databases come back empty and the next
./localenv up migrates them.
For queues it removes the servicebus-emulator and servicebus-mssql containers. The emulator
has no purge command, so removing its backing store is the reset. The emulator keeps its queues,
topics and subscriptions in servicebus-mssql, which declares no volume, and rebuilds the
topology from the generated Config.json on the next ./localenv up.
It refuses to run while a stack is up, prompts before doing anything unless given -y, and
skips any service whose services/<name>/.env points elsewhere.
The Postgres volume is kept — dropping the databases inside it is enough.
The first ./localenv up after a reset takes about 40 seconds to rebuild the emulator's
topology, because it waits on a cold SQL Server before creating anything. Compose waits until
SQL answers a query before starting the emulator; without that, a recreate after Config.json
is regenerated fails with Name or service not known. Docker still reports the emulator
container healthy well before the topology exists, so a publish in that window fails on a
queue that does not exist yet. Wait for Emulator Service is Successfully Up! in
docker logs servicebus-emulator.
Starting from the dev data
An empty database is not always what you want. seed copies the dev environment's databases into
the local ones; restore puts a local database back to the last dump taken of it.
./localenv seed # dump from dev, restore, migrate
./localenv restore # restore the newest local dump, migrate
./localenv restore --list # what is on this machine, and how oldBoth open the same picker over the services whose local database this repository owns, so you
choose which databases to replace. Both drop and recreate each one before loading the dump, for the
reason reset drops rather than truncates, and both refuse to run while a stack is up.
The dump is a full one: schema, data, and goose's goose_db_version table. Restoring the version
table puts the local database at the version the dump came from, and the migration that follows
applies only the migrations the dump does not have — usually the ones you have written but not
deployed. That is what lets a week-old dump come up correct against today's branch. --no-migrate
leaves the migration to the next ./localenv up.
Dumps are kept in the gitignored .localenv/dumps/, named <service>-dev-<timestamp>.dump.
./localenv seed --dump-only takes one without giving up the local database you are using.
seed is the half that leaves the machine. It needs sops on PATH, an
az login --scope https://vault.azure.net/.default, and your current IP allowed on the dev
Postgres firewall — a dump that hangs and then times out is almost always the firewall.
restore needs none of it: no network, no Azure login, no sops.
The dev DATABASE_URL comes from the same SOPS-encrypted secrets.dev.yaml the deploy reads. It is
decrypted in memory, handed to pg_dump on standard input rather than as an argument, and never
printed. Both pg_dump and pg_restore run inside the Postgres container, so neither needs a host
libpq. Production is deliberately not on offer.
Pointing a service somewhere else
Put DATABASE_URL in services/<name>/.env, which is never committed. It wins over the default for
every process of that service, so the migration, the API, and any worker beside them cannot drift
apart. ./localenv reads the file on each start and leaves that service out of db-provision,
because the database is then yours to manage. Comment the line out to go back to the default.
make migrate and the Makefile service-start shortcuts do not read services/<name>/.env.
Use ./localenv when a service has one. make dev-seed delegates to localenv and does
read these overrides.
Authentication
Ory Kratos and Ory Hydra start with the rest of the Docker Compose infrastructure. Hydra
serves the discovery document and the JWKS on http://127.0.0.1:4444 and Kratos the
self-service flows on http://127.0.0.1:4433; both admin listeners are unauthenticated and
all four ports are published on 127.0.0.1 only. Configuration is in
services/authn. For what runs, the local-only settings, and the
commands that register a client and mint a token by hand, see
docs/local-dev.md.
Makefile shortcuts
make dev-up
make migrate
make dev-seed # principals for Cedar authz in dev
make dev # accounting on :8080
make dev-template # template on :8081, Swagger at /api-docsAfter clearing local databases, restore development principals in each service you use:
make dev-seed SERVICES="accounting leases billing"The accounting demo frontend sends X-Dev-Actor: fid_dev_admin to all three services. Each service
looks up that identity and its roles in its own database, so seeding Accounting alone does not
authorize requests to Leases or Billing. Accounting's older testdata/demo_seed.sql seeds
demo_ctrl and accounting demo data; it is not a replacement for these principal seeds.
make dev-seed delegates to ./localenv dev-seed. Without a SERVICES selection, the local runner
discovers all available services/<service>/testdata/dev-seed.sql fixtures. An explicit selection
is passed to the runner; selecting a service without a fixture fails before any seeds run.
You can also select services directly with ./localenv dev-seed accounting leases billing.
Run migrations first if the tables do not exist. Each script runs in a transaction and SQL errors
stop the command. The scripts can be rerun without removing existing data. The default local
database uses Docker's psql; per-service database overrides require a host psql and must point
to a loopback address. Seeding rejects an APP_ENV other than local or unset.
The accounting-seed startup helper also uses this runner, so starting Accounting with
the default Compose database does not require installing psql on the host.
Prefer ./localenv up --group template-stack when running several template processes together.
Template processes are derived from Azure Pipelines chart entries.
Remote production
Connect local processes to US production secrets (today prod in .azure/env/):
./localenv up --env prod template-apiRequires sops and access to decrypt services/template/.azure/env/secrets.prod.yaml.
API collection
bruno/ is a Bruno collection holding every HTTP API in the
repository, pointed at the local stack. Bruno keeps a collection as plain files in a directory,
so it is committed and reviewed like anything else.
./localenv up bruno # regenerate, register with Bruno, open the app
./localenv up bruno-smoke # run the readiness folder in the Bruno CLI container
./localenv smoke # the same check against a stack already up
make bruno # regenerate onlyOnce the app is open:
- Select
localin the environment dropdown, top right. Nothing sends until you do — with no environment picked, every URL is a literal{{accountingUrl}}rather than an address. - Start the processes you want to call, with
./localenvor./localenv up accounting-api. - Open a folder and send a request.
Only the processes you started answer. local-docker is the same URLs against
host.docker.internal, for bruno-smoke running in a container; leave it alone otherwise.
bruno-smoke asks only about the processes you started, and waits for each to report healthy
first. Both are decided per run, since the committed fragment is written before anyone has
chosen anything.
Bruno only shows collections listed in its own workspace — passing a directory to open
launches the app and is ignored — so up bruno adds bruno/ to workspace.yml in the active
workspace before launching, writing that file first if the app has never been opened.
./localenv setup does the same after installing Bruno. Neither writes while Bruno is running,
since the app rewrites the workspace on quit; when that blocks it, it says so.
The four service folders are generated from each service's openapi.yaml; platform,
bus-inspector, local-app, docs-app, devops-streams and smoke are hand-written, because
no spec describes those routes. Regeneration replaces the generated folders wholesale, so change
the handler rather than the request.
Base URLs come from tools/localenv/ports.yaml, so a request cannot name a
port the stack does not use. Idempotency-Key and X-Request-Id are set for you, and
X-Dev-Actor is the seeded principal that gets an accounting request past Cedar.
./localenv setup installs the desktop app with Homebrew, alongside Azure CLI, Helm, and
helm-secrets. Those are the things setup installs that the stack runs fine without, so a
machine with no Homebrew gets an install hint rather than a failed setup. The official
Docker image is the CLI only; there is no web UI to start instead.
See docs/local-dev.md for what each folder holds and how regeneration works.
Try accounting
curl -s -X POST localhost:8080/v1/accounts \
-H 'X-Dev-Actor: fid_dev_controller' \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"data":{"chart_code":"us","code":"10000","name":"ASSETS","classification":"asset","is_postable":false}}' | jqTry template
curl -s -X POST localhost:8081/v1/domain1 \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"data":{"value":"hello"}}' | jqAuthorization is not wired in the template service yet.
See docs/local-dev.md for the full reference including Service Bus emulator
topology, ./localenv commands, and database clients.
When the change is ready to share, follow Pull requests.