Flow

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 ./localenv builds 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 strictest engines.node of 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 needs ACCEPT_EULA=Y and accepts at most 50 queues and topics. Over that it exits with Out of memory. and code 139, so ./localenv up writes a config with only the entities the services you started use.
  • almost: AlmostServiceBus, one in-memory container with no entity limit. cmd/sbprovision creates 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 them

Stop the stack from another terminal:

./localenv down

down 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:

TabWhat it shows
Service BusQueue and subscription backlog for the local emulator
DatabaseSQL 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 :4433

Each 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 buffer

Databases

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.

ServiceDatabaseAccount
accountingaccountingpostgres / postgres
templatetemplatepostgres / postgres
authn (Ory Hydra)hydrapostgres / postgres
authn (Ory Kratos)kratospostgres / 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 reset

reset 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 old

Both 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-docs

After 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-api

Requires 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 only

Once the app is open:

  1. Select local in the environment dropdown, top right. Nothing sends until you do — with no environment picked, every URL is a literal {{accountingUrl}} rather than an address.
  2. Start the processes you want to call, with ./localenv or ./localenv up accounting-api.
  3. 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}}' | jq

Try template

curl -s -X POST localhost:8081/v1/domain1 \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{"data":{"value":"hello"}}' | jq

Authorization 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.

On this page