Skip to Content

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 shortcutsr to restart everything from scratch, q to quit, h for help.

Pipeline

Each stage can be opted out independently.

  1. Preflight — verify docker and docker compose availability.
  2. Fresh volumes (optional, --fresh) — drop every compose volume before starting.
  3. Service startdocker compose --profile cache --profile queue up -d <resolved services>.
  4. Health-wait — poll each service’s compose healthcheck until healthy (timeout 30s by default).
  5. Migratemigrate up against the now-healthy database. Skipped under --all-in-docker — the dev container runs migrate itself before Air.
  6. Seed (optional, --seed) — run seeders after migrations.
  7. Air — exec go tool air on the host against .air.toml. Under --all-in-docker, instead tail the app container’s stdout via docker compose logs -f app.
  8. Teardown — on SIGINT/SIGTERM or q, stop services (volumes preserved by default). On r, 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

FlagDefaultBehavior
--all-in-dockerfalseRun 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-servicesfalseSkip all compose orchestration; just run Air (for an externally-managed database).
--no-dbfalseSkip DB-like services (postgres, mysql, clickhouse, mariadb, or anything suffixed -db).
--no-cachefalseSkip the cache compose profile (which is auto-activated by default).
--no-queuefalseSkip the queue compose profile (which is auto-activated by default).
--no-migratefalseSkip migrate up after services become healthy.
--no-keyboardfalseDisable the interactive keyboard layer. Use this when stdin is piped or for non-interactive sessions. Auto-disabled when stdin is not a TTY.
--no-teardownfalseLeave compose services running on exit (default: stop them).
--keep-volumestruePreserve named volumes on teardown. Pass --keep-volumes=false to run docker compose down -v instead of docker compose stop.
--freshfalseDrop 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>30sHow long to wait for compose services to report healthy.
--env-file=<path>.envAlternate env file to load before Air.
--port=<n>(from config)Override the PORT env var passed to Air / the app binary.
--rebuildfalseClear Air’s tmp/ build directory before starting so the next build is fresh. Host-Air-only; no-op under --all-in-docker.
--seedfalseRun seeders after migrations (equivalent to gofasta seed afterward).
--dry-runfalsePrint the resolved plan and exit without touching anything.
--attach-logsfalseStream docker compose logs -f for every service alongside Air. Under --all-in-docker this multiplexes db/cache/queue alongside the app’s foreground logs.
--dashboardfalseStart the local dev dashboard — an HTML debug page with routes, health, and live service state.
--dashboard-port=<n>9090Port for the dev dashboard HTTP server.
--jsonfalse (persistent)Emit structured NDJSON events instead of human log lines.

Flag resolution priority

  1. --no-services wins unconditionally — start nothing.
  2. --services=a,b,c overrides --no-* filters — start exactly those.
  3. Default: every non-app service in compose.yaml, minus any matched by --no-* name heuristics. The app service is included only under --all-in-docker.

Compose profile resolution. The profile list passed to docker compose --profile is built from:

  1. Any value of --profile=<name>.
  2. cache — unless --no-cache is set.
  3. queue — unless --no-queue is 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:

KeyAction
r / RRestart 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 / QQuit 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 help

The 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 .env or config.yaml and 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 -d cycle.
  • 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-docker

What changes vs. the default mode:

StageDefault--all-in-docker
Service startcompose up -d <db, cache, queue>compose up -d <app, db, cache, queue>
MigrateHost runs migrate up against localhost:5433Skipped on host — the dev container’s CMD runs migrate against db:5432 before starting Air.
Airgo tool air on the hostAir 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

CodeWhen
DEV_DOCKER_UNAVAILABLEdocker not on $PATH or daemon unreachable
DEV_COMPOSE_NOT_FOUNDNo compose.yaml but --services or --all-in-docker was set
DEV_SERVICE_UNHEALTHYA service didn’t become healthy within --wait-timeout
DEV_MIGRATION_FAILEDmigrate up returned non-zero
DEV_AIR_NOT_INSTALLEDgo tool air not registered in go.mod
DEV_PORT_IN_USEThe configured PORT is already bound
DEV_FLAG_CONFLICTTwo 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, /metrics when 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:

EndpointPurpose
GET /HTML page with server-side-rendered initial state and an SSE subscriber for live updates
GET /api/stateCurrent dashboardState snapshot as JSON
GET /api/streamServer-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/replayRe-fire a captured request. Body: { "method": "POST", "path": "/api/v1/orders", "body": "..." }
POST /api/explainRun EXPLAIN against the registered GORM DB. Body: { "sql": "SELECT ...", "vars": ["a", "b"] }
GET /api/harDownload 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.

EndpointPurpose
GET /debug/requestsCaptured RequestEntry ring (method, path, status, duration, trace ID, body, response body)
GET /debug/sqlCaptured QueryEntry ring (SQL, vars, rows, duration, trace ID)
GET /debug/tracesSummary 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/errorsRecent panics with stacks
GET /debug/cacheRecent cache ops
POST /debug/explainEXPLAIN 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-keyboard

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

  1. SIGINTs Air (or sends teardown to the app container under --all-in-docker).
  2. Runs the configured teardown — compose stop by default, or compose down -v if --keep-volumes=false.
  3. Re-loads .env (so edits to it apply).
  4. 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.

Last updated on