Deployment

GoFastr apps compile to a single static binary with templates, runtime JS, and (optionally) embedded migrations baked in. That makes deployment boring in the good way: build one binary, run it with a few env vars.

The single-binary model

go build your main package → one executable. It serves HTTP, runs auto-migrations on Start, and embeds the UI runtime. No Node, no asset pipeline, no sidecar.

2 lines
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o app ./
./app                 # listens on :8080 (or $PORT)

The CLI runs generation, project-wide vet, and the accessibility gate before building. It targets the project root by default; when the executable lives under cmd/, select only the final main package with --pkg. Vet and accessibility checks still run from the project root, matching gofastr dev:

2 lines
gofastr build
gofastr build --pkg ./cmd/server

Go version. go.mod declares go 1.27.3. The patch level is a security floor: Go 1.27.0 to 1.27.2 build crypto/x509 with GO-2026-6809, which the SMTP sender's TLS handshake reaches. go get raises an app's own go line to match, and the default GOTOOLCHAIN=auto downloads 1.27.3; with GOTOOLCHAIN=local, install it first. The framework uses the 1.27 standard library directly: uuid for request ids and entity keys, strings.CutLast, and the runtime's goroutineleak profile behind /.debug/goroutineleak. Go 1.21+ refuses to build a module whose go directive is below a dependency's, so the floor can only rise: chromedp (v0.15.1, go 1.26) is a direct dependency of the browser-driven tooling in framework/testkit/axetest, the CLI accessibility audit, and the eval harness. battery/print/chromepdf is a nested module that isolates the print feature's own chromedp usage and does not set the floor.

SQLite vs Postgres. The bundled gofastr CLI uses SQLite. For a Postgres deployment, import a Postgres driver in your app and pass a *sql.DB via framework.WithDB. CGO_ENABLED=0 works with the pure-Go jackc/pgx stdlib driver; the mattn/go-sqlite3 driver needs CGO, so choose your base image accordingly (see the Dockerfile note below).

Production Dockerfile

Multi-stage, distroless runtime, non-root, pure-Go build (Postgres):

15 lines
# ---- build ----
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./

# ---- runtime ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
ENV PORT=8080
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Using the CGO SQLite driver (mattn/go-sqlite3) instead? Build with CGO_ENABLED=1 on golang:1.27 and run on gcr.io/distroless/base-debian12 (has libc) rather than static.

Configuration (env)

GoFastr reads config from the environment. NewApp also loads .env files at boot in every environment, not just development; set GOFASTR_DOTENV=off to suppress it, see dotenv; real env always wins. Common vars:

VarPurpose
PORTListen port. A bare value like 8080 is normalized to :8080, so PaaS-injected $PORT works.
DATABASE_URLYour app reads this and passes the connection to WithDB.
APP_ENVAdds .env.<APP_ENV> to the dotenv load order whenever it is set.
auth secretsIf you use battery/auth, set its JWT/session secret explicitly in production. Do not rely on the dev auto-generated secret (it rotates per process and silently invalidates sessions). See auth.
GOFASTR_SECRETHMAC signing key for the uihost session token. Set it (or use framework.WithSecret in code) so a session token issued by one replica verifies on every other replica. With one replica and no secret, an ephemeral boot secret is minted (sessions roll over on restart). With WithFanout and no secret, the app refuses to boot. See Reactivity model and Horizontal scaling.

Secrets

GoFastr reads secrets from the process environment. It does not bundle a secrets manager, and .env files are a development convenience only (never commit them, never ship them in the image). In production, inject secrets as env vars from your platform's secret store:

  • Kubernetes: a Secret mounted as env vars (or via the CSI secrets store driver).
  • AWS: Secrets Manager / SSM Parameter Store → env at task start (ECS task definition secrets:, or fetch-on-boot).
  • Vault: the Vault Agent injector or vault kv get in an init step.

Rotating secrets without a mass logout

Both the uihost session key (GOFASTR_SECRET) and the auth JWT key (AuthConfig.JWTSecret) rotate gracefully: new tokens are signed with the new secret, and verification accepts the new or the previous secret for one token lifetime, after which you drop the old one.

  • GOFASTR_SECRET: set GOFASTR_SECRET to the new value and GOFASTR_SECRET_PREVIOUS to the old (comma-separated to roll through several; each ≥32 chars). In code: framework.WithSecretRotation(newSecret, oldSecret). Deploy to every replica, wait one session TTL, then unset GOFASTR_SECRET_PREVIOUS and redeploy. GOFASTR_SECRET is used exactly as set, surrounding whitespace included; an old value that carries some (a trailing newline from a file, a pasted space or tab) is accepted in GOFASTR_SECRET_PREVIOUS both with and without it. In a comma-separated list the spaces around each comma are layout, so only an entry's line break is kept there: move such a value in on its own.
  • AuthConfig.JWTSecret: set JWTSecret to the new value and move the old into JWTPreviousSecrets. Wait one JWTExpiry (default 1h), then remove it and redeploy.

Omitting the previous key, the pre-rotation behavior, still invalidates every outstanding token at once. Use the previous-key form only during an active rotation. See security for the underlying key rules.

The one secret every auth-enabled app must set is AuthConfig.JWTSecret (typically from env). With DevMode=false and no JWTSecret, the auth battery fails closed: Init returns an error and the app refuses to start, because an empty signing key yields forgeable, restart-unstable sessions. In dev, a per-process secret is auto-minted (with a WARN log) so the boilerplate never ships a literal change-me.

Migrations

App.Start auto-migrates on boot: it creates missing tables and adds missing columns (additive only: it never drops, renames, or retypes; those need a reviewed versioned migration from gofastr migrate generate, applied with gofastr migrate up). For controlled rollouts, run migrations as a separate step with the CLI instead of on every replica's boot:

2 lines
gofastr migrate up --db-url="$DATABASE_URL"
gofastr migrate status --db-url="$DATABASE_URL"

See Migrations for the production-hardening details (locking, checksums, dirty-state, destructive-change gating).

TLS & graceful shutdown

Terminate TLS at your ingress/load balancer (the common setup) and run the app over plain HTTP behind it. When nothing sits in front, the app can serve HTTPS itself (next section). App.Start installs signal handling and drains in-flight requests on SIGINT/SIGTERM via App.Shutdown, so rolling deploys don't cut active requests.

First, /readyz turns 503 and the app keeps serving for the readiness grace (default 5s, framework.WithReadinessDrain; see Health and readiness), so the load balancer stops routing here before the listener closes. /healthz stays 200 until exit. The grace counts against Shutdown's deadline and takes at most half of it; an app bound to loopback only, or run by battery/service, skips it.

The drain after it is bounded: AppConfig.ShutdownTimeout (default 15s, on top of the grace) caps it, and anything still open at the deadline is force-closed so the process exits well inside Kubernetes' 30s SIGTERM→SIGKILL window. Long-lived connections never go idle, so the drain does not wait on them; it ends them when it starts, and the client reconnects to another replica or to the restarted process:

  • An event stream (a GET whose response goes out as text/event-stream, whatever the request's Accept said) has its request context cancelled, so its handler returns. A POST answered as a stream (MCP's streamable HTTP) is one call's reply and drains like any other request.
  • A WebSocket opened with core/stream's Upgrade (the battery/rtc signaler included) gets a close frame with code 1001, going away, even if the handler that opened it has returned. A connection a handler hijacked by hand is closed. Neither would end otherwise: http.Server.Shutdown does not wait for or close a hijacked connection. A hijack attempted after the drain started is refused, and its connection closed. The drain gives the close handshakes up to 2s (less if the Shutdown context ends first), then closes any connection whose peer has not answered.

A connection that was accepted but never sent a request (a browser's speculative preconnect) is closed once the listener stops accepting, so it cannot hold the drain to its deadline. That covers one accepted while the drain was ending the streams above. In-flight cron jobs are joined under the same deadline (their contexts are cancelled when the drain starts). If your process owns signal handling itself, set AppConfig.DisableSignalHandling and call App.Shutdown (or App.RunWithSignals) from your own handler. Shutdown is idempotent, so double-wiring is harmless: the teardown runs once, and a later or concurrent call waits for it to finish (or for its own context to end) before it returns.

Serving HTTPS from the app

framework.WithTLS (or AppConfig.TLS) serves HTTPS on the listen address. Pick one certificate source:

13 lines
// Files on disk, e.g. from certbot. A renewal is picked up on the next
// handshake after either file changes; no restart.
framework.WithTLS(framework.TLSConfig{
    CertFile: "/etc/letsencrypt/live/example.com/fullchain.pem",
    KeyFile:  "/etc/letsencrypt/live/example.com/privkey.pem",
})

// Let's Encrypt, obtained and renewed by the app.
framework.WithTLS(framework.TLSConfig{AutoCert: &framework.AutoCertConfig{
    Domains:  []string{"example.com", "www.example.com"},
    CacheDir: "/var/lib/myapp/certs",
    Email:    "ops@example.com",
}})

The third source, Config, takes a *tls.Config you build (client certificates, a certificate store). Setting none or more than one fails Start, as does a certificate pair that does not load.

  • TLS 1.2 is the minimum for every source; a Config asking for less fails Start.
  • HTTP/2 is offered over TLS.
  • AutoCert answers the TLS-ALPN-01 challenge on the HTTPS port, so the app must be reachable on :443 at every name in Domains and nothing needs to listen on :80. Handshakes for other names are refused. CacheDir keeps certificates across restarts; without it each restart asks Let's Encrypt again and hits its rate limits. Setting AutoCert accepts the authority's terms of service; set DirectoryURL to the staging directory while testing a deployment.
  • The startup banner prints https:// URLs, and the Secure cookie and HSTS checks see the connection as HTTPS.

Plain HTTP on the same port gets net/http's 400 answer. To redirect port 80, run a second listener or let the load balancer do it.

HTTP versions

framework.WithHTTPProtocols (or AppConfig.HTTPProtocols) passes an http.Protocols to the server. Unset, the app speaks HTTP/1.1, plus HTTP/2 when TLS is on. A proxy that speaks HTTP/2 to the app without TLS (h2c, prior knowledge) needs:

4 lines
var p http.Protocols
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true)
app := framework.NewApp(framework.WithHTTPProtocols(p))

h2c by Upgrade: h2c is not supported, and HTTP/3 is not offered.

HTTP server timeouts

App.Start builds the embedded http.Server with four connection deadlines:

FieldDefaultBounds
ReadHeaderTimeout10sTime to receive the request headers (slowloris defence). Keep this set.
ReadTimeout60sWhole request read: headers + body. Caps upload time.
WriteTimeout60sWhole response write, from first header byte to the last. Caps every response.
IdleTimeout120sHow long a keep-alive connection may sit idle between requests.

These used to be hardcoded; a handler that needed to run longer than 60s was out of luck regardless of its own ctx deadline. Override any of them through framework.WithHTTPServerTimeouts (or the AppConfig.HTTPServerTimeouts field). Each field is a *time.Duration: leave it nil to keep the default, or point at a value, including 0, which disables that one deadline (matching net/http, where a zero duration means "no timeout"). Build the pointers with the new(expr) builtin:

4 lines
app := framework.NewApp(framework.WithHTTPServerTimeouts(framework.HTTPServerTimeoutsConfig{
    WriteTimeout: new(2 * time.Minute), // slow report handler
    ReadTimeout:  new(time.Duration(0)), // disable: accept arbitrarily large uploads
}))

AppConfig.DisableRequestTimeout is the coarser, older knob: it removes the per-request Timeout middleware from the chain and zeroes the server ReadTimeout/WriteTimeout. An explicit HTTPServerTimeouts value wins over that zeroing, so you can drop the middleware yet keep a custom deadline: set the field you want to keep and leave DisableRequestTimeout for the rest.

Long-running requests

A request that legitimately outlives the default WriteTimeout, a slow report, a large export, an AI completion, needs the deadline raised or removed for that path:

4 lines
// Raise the server write deadline app-wide for a report-heavy service.
app := framework.NewApp(framework.WithHTTPServerTimeouts(framework.HTTPServerTimeoutsConfig{
    WriteTimeout: new(2 * time.Minute),
}))

Prefer the smallest raise that covers your slowest real request over disabling the deadline entirely. WriteTimeout is a whole-response backstop: without it, a handler that hangs holds its connection (and a goroutine) forever. A RequestTimeout raised past the write deadline raises an unset WriteTimeout to the budget plus 10s; an explicit WriteTimeout no longer than an explicit RequestTimeout makes Start return an error, since every buffered response finished after the write deadline would be lost. 0 still disables it.

The per-request middleware deadline (AppConfig.RequestTimeout, default 30s) can be overridden per route or per group instead of app-wide:

9 lines
app := framework.NewApp()
// One slow dashboard keeps its own budget; every other route stays at 30s.
app.Router().SetRouteTimeout("GET", "/reports/{id}", 2*time.Minute)
// Or budget a whole group; the nearest enclosing group wins, and an
// exact SetRouteTimeout beats any group.
admin := app.Router().Group("/admin")
admin.SetTimeout(5 * time.Minute)
// router.NoTimeout exempts a route from the middleware deadline entirely.
app.Router().SetRouteTimeout("POST", "/exports", router.NoTimeout)

When the deadline fires, the 504 logs a structured request timeout line naming the method, path, budget, and — when a route or group override is configured — the matched pattern. Two caveats: the server-level WriteTimeout above still bounds the whole response, so a route budget beyond it needs WithHTTPServerTimeouts raised too; and DisableRequestTimeout removes the middleware entirely, taking the per-route budgets with it.

A buffered handler that finishes at or after its budget returns 504 even when the middleware observes its completion before the timer notification. A handler that finishes before the deadline keeps its successful response.

SSE and streaming

WriteTimeout bounds the whole response, and a Server-Sent Events stream is a single response, so the stream is cut when the deadline lapses. That does not break the framework's /__gofastr/sse bus: the browser's EventSource reconnects automatically, so live updates keep flowing at any WriteTimeout. The default 60s simply means an idle-enough stream reconnects at most once a minute. For a presence/collaboration surface where you want fewer reconnects on a single long-lived stream, raise WriteTimeout or set it to 0. The per-request middleware deadline is a separate concern: a handler that flushes (as every SSE subscription does) already sheds it at first flush; see Reactivity.

Running more than one replica

Everything on this page assumes one replica. Sessions, rate limits, cron, in-memory queues, and SSE push are process-local by default and need a shared backend (or a single-runner strategy) before you scale out. See Horizontal scaling for the complete what-breaks/what-fixes-it list.

Health & metrics

  • Readiness/liveness: auto-registered probes (plus a DB readiness check when a DB is configured). Point your orchestrator's probes at them. See Health checks.
  • Metrics: enable framework.WithMetrics() to expose Prometheus /metrics; enable framework.WithTracing() for OpenTelemetry. See Observability. Scrape /metrics from inside the cluster. It is unauthenticated by design.

Checklist

  • [ ] CGO_ENABLED matches your DB driver (0 for pgx, 1 for go-sqlite3).
  • [ ] Auth/session secret set explicitly (not the dev default).
  • [ ] GOFASTR_SECRET set (or framework.WithSecret in code) before running a second replica. Required with WithFanout.
  • [ ] Migrations run as a deploy step (or accepted on-boot for single-replica).
  • [ ] Readiness/liveness probes wired.
  • [ ] /metrics scraped from inside the network only.
  • [ ] TLS terminated at ingress, or served by the app with WithTLS.
  • [ ] Database backup scheduled and a restore actually tested. See Backups and restore.
  • [ ] Server timeouts fit your slowest real request. Raise WriteTimeout via WithHTTPServerTimeouts for report/export/AI endpoints instead of letting the 60s default cut them.

Common mistakes

  • Shipping .env files in the image. They are a development convenience only. In production, inject secrets as real env vars from your platform's secret store. Real env always wins over file values anyway, so a stowaway .env is at best dead weight and at worst a leaked secret.
  • Expecting boot auto-migrate to handle every schema change. It creates tables and adds columns, additive only. Drops, renames, and type changes need a reviewed versioned migration (gofastr migrate generate <name> then gofastr migrate up); booting won't do them.
  • CGO flag and base image out of sync. CGO_ENABLED=0 with mattn/go-sqlite3 fails at build; a CGO build on distroless/static fails at runtime (no libc, so use base-debian12). Pure-Go pgx is what makes the static image work.
  • Booting production auth without a JWTSecret. With DevMode=false and no JWTSecret, the battery refuses to start (Init errors, App.Start fails). There is no auto-minted fallback in production: the dev fallback rotates per process, which would silently invalidate sessions on every deploy. Set the secret explicitly from env.
  • A slow handler dies at exactly 60s. The server's WriteTimeout is a 60s default; a report, export, or AI-completion handler that runs longer is cut mid-response no matter what ctx deadline it sets. Raise WriteTimeout (or ReadTimeout for uploads) via WithHTTPServerTimeouts to fit your slowest real request, see "HTTP server timeouts" above. SSE is not affected the same way: EventSource reconnects when a stream is cut.