gofasta dev
One-command development loop. Runs the full pipeline: start compose services, wait for healthchecks, apply pending migrations, launch Air for hot-reload, stream structured events for agents, accept interactive keyboard controls, and tear everything down on Ctrl+C.
Usage
gofasta dev [flags]What runs by default
gofasta dev activates the cache and queue compose profiles automatically so your scaffolded project’s redis + asynqmon services come up alongside the database. Pass --no-cache / --no-queue to opt out.
A second mode, --all-in-docker, runs Air inside the app container and streams its hot-reload output to the foreground — useful when you want full Docker isolation but still want to see live logs.
While the dev loop is running, the terminal accepts single-key shortcuts — r to restart everything from scratch, q to quit, h for help.
Pipeline
Each stage can be opted out independently.
- Preflight — verify
dockeranddocker composeavailability. - Fresh volumes (optional,
--fresh) — drop every compose volume before starting. - Service start —
docker compose --profile cache --profile queue up -d <resolved services>. - Health-wait — poll each service’s compose healthcheck until
healthy(timeout 30s by default). - Migrate —
migrate upagainst the now-healthy database. Skipped under--all-in-docker— the dev container runsmigrateitself before Air. - Seed (optional,
--seed) — run seeders after migrations. - Air — exec
go tool airon the host against.air.toml. Under--all-in-docker, instead tail the app container’s stdout viadocker compose logs -f app. - Teardown — on
SIGINT/SIGTERMorq, stop services (volumes preserved by default). Onr, re-run the entire pipeline from scratch.
On projects without a compose.yaml, steps 1–4 are short-circuited and the pipeline falls straight to Air — preserving the “I’m bringing my own DB” flow.
Flags
| Flag | Default | Behavior |
|---|---|---|
--all-in-docker | false | Run the entire stack (including Air) inside Docker. Supporting services run detached; the app container’s stdout streams to the foreground for live hot-reload logs. See the dedicated section. |
--no-services | false | Skip all compose orchestration; just run Air (for an externally-managed database). |
--no-db | false | Skip DB-like services (postgres, mysql, clickhouse, mariadb, or anything suffixed -db). |
--no-cache | false | Skip the cache compose profile (which is auto-activated by default). |
--no-queue | false | Skip the queue compose profile (which is auto-activated by default). |
--no-migrate | false | Skip migrate up after services become healthy. |
--no-keyboard | false | Disable the interactive keyboard layer. Use this when stdin is piped or for non-interactive sessions. Auto-disabled when stdin is not a TTY. |
--no-teardown | false | Leave compose services running on exit (default: stop them). |
--keep-volumes | true | Preserve named volumes on teardown. Pass --keep-volumes=false to run docker compose down -v instead of docker compose stop. |
--fresh | false | Drop every compose volume before starting — forces a clean database state. |
--services=<list> | (derived) | Comma-separated explicit list. Overrides --no-* flags. |
--profile=<name> | (none) | Additional compose profile to activate. Cache and queue are auto-on unless --no-cache / --no-queue. |
--wait-timeout=<duration> | 30s | How long to wait for compose services to report healthy. |
--env-file=<path> | .env | Alternate env file to load before Air. |
--port=<n> | (from config) | Override the PORT env var passed to Air / the app binary. |
--rebuild | false | Clear Air’s tmp/ build directory before starting so the next build is fresh. Host-Air-only; no-op under --all-in-docker. |
--seed | false | Run seeders after migrations (equivalent to gofasta seed afterward). |
--dry-run | false | Print the resolved plan and exit without touching anything. |
--attach-logs | false | Stream docker compose logs -f for every service alongside Air. Under --all-in-docker this multiplexes db/cache/queue alongside the app’s foreground logs. |
--dashboard | false | Start the local dev dashboard — an HTML debug page with routes, health, and live service state. |
--dashboard-port=<n> | 9090 | Port for the dev dashboard HTTP server. |
--json | false (persistent) | Emit structured NDJSON events instead of human log lines. |
Flag resolution priority
--no-serviceswins unconditionally — start nothing.--services=a,b,coverrides--no-*filters — start exactly those.- Default: every non-app service in
compose.yaml, minus any matched by--no-*name heuristics. Theappservice is included only under--all-in-docker.
Compose profile resolution. The profile list passed to docker compose --profile is built from:
- Any value of
--profile=<name>. cache— unless--no-cacheis set.queue— unless--no-queueis set.
Duplicates are removed; order is stable. So the default activates [cache queue]; gofasta dev --no-cache activates [queue]; gofasta dev --no-cache --no-queue activates [].
Interactive keyboard controls
While the dev loop is running, gofasta puts stdin in raw mode and watches for these single-key shortcuts:
| Key | Action |
|---|---|
r / R | Restart the entire pipeline from scratch. Services stop, then every stage re-runs (.env reload → preflight → start services → wait healthy → migrate → seed → Air). Same effect as Ctrl+C and gofasta dev again, without leaving the process. |
q / Q | Quit cleanly. Equivalent to Ctrl+C — runs the existing teardown flow. |
h / H / ? | Print the keybinding help inline. |
When gofasta dev starts with the keyboard layer active you’ll see a one-line banner:
⌨ press `r` to restart · `q` to quit · `h` for helpThe keyboard listener auto-disables when stdin is not a TTY (CI runs, piped input). Pass --no-keyboard to disable it explicitly when stdin is needed by something else.
When you’d press R. Air handles Go-source reloads automatically — you don’t need R for that. Press R when something outside the Air-watched filesystem changes:
- You edited
.envorconfig.yamland want the new values picked up. - You added or rebased migration files and want them re-applied from a clean state with
--fresh. - A compose service got into a weird state and you want a fresh
up -dcycle. - You want to test cold-start behavior (full pipeline replay) without exiting the process.
—all-in-docker mode
Adds the app compose service to the orchestrated set so Air runs inside the app container rather than on the host. Supporting services (db, cache, queue) still run detached; the app container’s stdout (Air’s hot-reload output) is streamed to your terminal via docker compose logs -f app, so the experience matches host-side Air.
gofasta dev --all-in-dockerWhat changes vs. the default mode:
| Stage | Default | --all-in-docker |
|---|---|---|
| Service start | compose up -d <db, cache, queue> | compose up -d <app, db, cache, queue> |
| Migrate | Host runs migrate up against localhost:5433 | Skipped on host — the dev container’s CMD runs migrate against db:5432 before starting Air. |
| Air | go tool air on the host | Air runs inside the app container; gofasta tails its logs to the foreground. |
| JSON event | {"event":"air","status":"running"} | {"event":"air","status":"running-in-docker"} |
Mutual exclusions. --all-in-docker is incompatible with --no-services and --no-db (the in-container app needs the database). Both produce a DEV_FLAG_CONFLICT error before any side effect runs. The flag also requires an app service in compose.yaml — gofasta scaffolds one by default; if your project’s compose file is hand-authored without one, the same error fires.
Multiplexing other service logs. By default only the app’s logs reach the foreground. Pass --attach-logs alongside --all-in-docker to also stream db / cache / queue logs (compose prefixes each line with the service name).
Structured output (--json)
Pipe gofasta dev --json into jq or your CI. One NDJSON event per line:
{"event":"preflight","status":"ok","docker":"28.0.1","compose":"v2.26.0"}
{"event":"service","name":"db","status":"starting"}
{"event":"service","name":"db","status":"healthy","duration_ms":2840}
{"event":"migrate","status":"ok","applied":3}
{"event":"air","status":"running","port":8080,"urls":{"rest":"http://localhost:8080","metrics":"/metrics","swagger":"/swagger/index.html","health":"/health"}}
{"event":"shutdown","teardown":"stopped","exit":0}Under --all-in-docker the air event carries "status":"running-in-docker" instead of "running" so consumers can branch on the runtime:
{"event":"migrate","status":"skipped","message":"running inside the app container"}
{"event":"air","status":"running-in-docker","port":8080,"urls":{"rest":"http://localhost:8080","health":"/health"}}Error codes
| Code | When |
|---|---|
DEV_DOCKER_UNAVAILABLE | docker not on $PATH or daemon unreachable |
DEV_COMPOSE_NOT_FOUND | No compose.yaml but --services or --all-in-docker was set |
DEV_SERVICE_UNHEALTHY | A service didn’t become healthy within --wait-timeout |
DEV_MIGRATION_FAILED | migrate up returned non-zero |
DEV_AIR_NOT_INSTALLED | go tool air not registered in go.mod |
DEV_PORT_IN_USE | The configured PORT is already bound |
DEV_FLAG_CONFLICT | Two flags asked for incompatible behavior — e.g. --all-in-docker --no-services, --all-in-docker --no-db, or --all-in-docker against a compose.yaml with no app service. |
Each carries a hint and docs field in the JSON error payload — agents can branch on the code and surface the hint verbatim.
The dashboard
gofasta dev --dashboard starts a tiny HTTP server on :9090 (configurable via --dashboard-port) that serves a live HTML debug page:
- Current app health (polled every 5s against
/health) - Running compose services + their state
- Registered REST routes (scraped from
docs/swagger.json, including request/response Go types) - Direct links to
/swagger,/graphql,/metricswhen the project exposes them - Prometheus metrics (request totals, in-flight gauge, average latency)
- Recent requests ring buffer (method, path, status, duration, trace ID, replay button)
- Recent SQL queries with rows affected and duration
- Per-request trace waterfall with nested spans, stack snapshots, and span events
Updates stream over Server-Sent Events at /api/stream; the JSON snapshot is at /api/state. The dashboard dies with the rest of the pipeline on Ctrl+C.
Full-request inspection (Levels 1–4)
When the app is built with the devtools build tag (gofasta dev sets this automatically via GOFLAGS), the dashboard stops being a read-only view and becomes an interactive inspector:
Level 1 — Trace waterfall. An OpenTelemetry SpanProcessor registered by devtools.RegisterTraceProcessor() snapshots every span into an in-memory ring keyed by trace ID. Click a trace row to see a waterfall of nested spans — controller → service → repository → SQL — with offsets and durations drawn to scale.
Level 2 — Stack snapshots per span. At span start, the processor records up to 20 call frames (runtime.Callers) so the dashboard can show exactly where in your source a span was opened. Click the ▸ beside a span to reveal its stack.
Level 3 — Auto-instrumented service/repository layers. The scaffold’s generators emit otel.Tracer().Start(ctx, "Service.Method") at the entry of every generated service and repository method. Any resource scaffolded with gofasta g scaffold contributes spans to the waterfall out of the box — no manual instrumentation needed.
Level 4 — Request replay. Every captured request keeps its body (capped at 64 KiB) in the ring. The Replay button on any row POSTs that request back to the live app. Mutating methods (POST / PUT / PATCH / DELETE) prompt for confirmation first. The response body and status appear inline so you can iterate on a bug without re-clicking through the UI.
All four levels are no-cost in production: the devtools package ships a stub (//go:build !devtools) whose middleware is a pass-through and whose RegisterTraceProcessor is a no-op. go build without -tags devtools dead-code-eliminates every debug surface.
Deep-inspection features
The dashboard isn’t just a live view — it’s an interactive inspector covering the full request lifecycle. Everything below is gated behind the devtools build tag (set automatically by gofasta dev); production builds compile the stub and pay zero cost.
Go runtime profiles (pprof). net/http/pprof is mounted under /debug/pprof/ whenever the app runs with the devtools tag. The dashboard surfaces a Profiles panel with one-click links to CPU (30s), heap, goroutine, mutex, block, allocations, and execution trace profiles. Open them in go tool pprof or view inline via the browser UI.
Goroutine inspector. The dashboard parses /debug/pprof/goroutine?debug=2 and groups live goroutines by top-of-stack function — click to expand per-bucket stacks. Spotting a runaway goroutine leak is a single refresh away.
N+1 query detection. Every captured SQL statement carries its trace ID and a normalized template (literals replaced with ?). The dashboard groups by (trace, template); any trace with ≥3 duplicate templates surfaces as an N+1 finding with the offending template shown. Click the trace link to see exactly which request caused it.
EXPLAIN on click. Each captured SELECT gets an EXPLAIN button. Clicking it opens a modal that ships the SQL + captured parameter values to the app’s /debug/explain endpoint — the app runs EXPLAIN against GORM and returns the plan. A SELECT-only whitelist prevents accidentally re-executing DML.
Per-request log viewer. devtools.WrapLogger decorates the project’s *slog.Logger so every record is teed into an in-memory ring keyed by trace ID. Expand any trace row → switch to the Logs tab → see exactly which log lines that request emitted, with levels and attributes.
Panic + exception history. devtools.Recovery wraps pkg/middleware.Recovery. Every recovered panic pushes an entry into the exceptions ring — recovered value, full stack, originating method + path, and the trace ID of the failing request. The Exceptions section surfaces the last 50 entries.
Cache hit-miss log. devtools.WrapCache decorates cache.CacheService so every Get/Set/Delete/Flush/Ping is recorded with op, key, hit/miss flag, and duration. Click a trace ID to see which cache ops happened during that request.
Queue inspector. When the scaffold’s asynqmon compose service (profile=queue) is running, the dashboard detects it and surfaces a direct link in the App cards row.
Edit-and-replay. The single-click Replay has been upgraded to an inline editor — click Replay, tweak the captured body in a textarea, hit Send. Response status + body appear inline; mutating methods prompt for confirmation first.
HAR export. The Export HAR button in the Recent Requests header downloads the current ring as HAR 1.2 JSON, importable into Chrome DevTools, Insomnia, Postman, or any HAR-aware viewer.
Dashboard endpoints
The dashboard HTTP server also exposes a small JSON API for agents and CI hooks:
| Endpoint | Purpose |
|---|---|
GET / | HTML page with server-side-rendered initial state and an SSE subscriber for live updates |
GET /api/state | Current dashboardState snapshot as JSON |
GET /api/stream | Server-Sent Events stream (5s cadence) |
GET /api/trace/{id} | Full TraceEntry — every span, stack, event, attribute |
GET /api/logs?trace_id=<id>&level=<INFO> | Slog records filtered by trace / level |
POST /api/replay | Re-fire a captured request. Body: { "method": "POST", "path": "/api/v1/orders", "body": "..." } |
POST /api/explain | Run EXPLAIN against the registered GORM DB. Body: { "sql": "SELECT ...", "vars": ["a", "b"] } |
GET /api/har | Download the current request ring as HAR 1.2 JSON |
Scaffold-side debug endpoints
These live on the app itself (mounted under /debug/ by devtools.Handler()), not on the dashboard. The dashboard scrapes them on a 5s refresher.
| Endpoint | Purpose |
|---|---|
GET /debug/requests | Captured RequestEntry ring (method, path, status, duration, trace ID, body, response body) |
GET /debug/sql | Captured QueryEntry ring (SQL, vars, rows, duration, trace ID) |
GET /debug/traces | Summary list of completed trace waterfalls |
GET /debug/traces/{id} | One full trace including every span, stack, event |
GET /debug/logs?trace_id=&level= | Filtered slog ring |
GET /debug/errors | Recent panics with stacks |
GET /debug/cache | Recent cache ops |
POST /debug/explain | EXPLAIN a SELECT |
GET /debug/pprof/* | Standard Go profiling endpoints |
GET /debug/health | {"devtools":"enabled"} or {"devtools":"stub"} |
Recipes
# Everything up — db, cache (redis), queue (asynqmon), Air on the host. Ctrl+C to stop.
gofasta dev
# Just the database — drop cache + queue.
gofasta dev --no-cache --no-queue
# Run the entire stack inside Docker. Air runs in the app container; its
# logs stream to your terminal so the experience matches host-Air.
gofasta dev --all-in-docker
# --all-in-docker plus multiplexed db/cache/queue logs in the same terminal.
gofasta dev --all-in-docker --attach-logs
# Bring up services but run Air pointing at a different port.
gofasta dev --port 8090
# Host-run app with your own Postgres — bypass compose entirely.
gofasta dev --no-services
# Debug Air itself — no database, no migrations, just the rebuild loop.
gofasta dev --no-services --no-migrate
# Start with a fresh, seeded database every time.
gofasta dev --fresh --seed
# Add an extra profile beyond the auto-on cache + queue.
gofasta dev --profile observability
# Dev dashboard + log streaming + JSON mode for a CI run.
gofasta dev --dashboard --attach-logs --json
# Print the plan without running it — useful in CI sanity checks.
gofasta dev --dry-run --json
# Disable interactive keyboard (e.g. when a parent process owns stdin).
gofasta dev --no-keyboardRestart workflow
Once the dev loop is running, you don’t need to exit it to re-run from scratch — press r at any time. The pipeline:
- SIGINTs Air (or sends teardown to the app container under
--all-in-docker). - Runs the configured teardown —
compose stopby default, orcompose down -vif--keep-volumes=false. - Re-loads
.env(so edits to it apply). - Re-runs every stage from preflight through Air.
A short banner is printed between iterations:
⟳ restarting from scratch (iteration 2)If the keyboard layer is disabled (non-TTY or --no-keyboard), restart is unavailable — exit and re-invoke gofasta dev instead.