Production Deployment
Quark in development is one call: quark.New(driver, dsn). Quark in production
is the same call plus deliberate answers to five questions:
- How big should the connection pool be?
- How long may a query run?
- Is the wire encrypted?
- Who runs migrations, and when?
- How do you see what the ORM is doing?
This page works through them in that order, after one build-time note that catches people out when they containerize.
Static binaries and cross-compilation
CGO_ENABLED=0 go build works. That is the default for scratch and
distroless Dockerfiles and for every cross-compile
(GOOS=linux GOARCH=arm64 …), so nothing here needs special handling.
The one cgo-dependent piece is the mattn/go-sqlite3 error classifier, and it
sits behind a build tag. Pure-Go builds classify SQLite errors through
modernc.org/sqlite instead, using the same numeric codes — that is the driver
the installation guide recommends anyway.
So the only rule is: if you use SQLite in a static binary, use
modernc.org/sqlite. Every other engine's driver is already pure Go.
Size the connection pool
Quark builds on database/sql, and pool options are applied to the underlying
*sql.DB before the client is created. The database/sql defaults are wrong
for most servers — unlimited open connections and only two idle ones — so set
them explicitly:
client, err := quark.New("pgx", dsn,
quark.WithMaxOpenConns(25),
quark.WithMaxIdleConns(25),
quark.WithConnMaxLifetime(30*time.Minute),
quark.WithConnMaxIdleTime(5*time.Minute),
)
| Option | What it sets | Guidance |
|---|---|---|
WithMaxOpenConns(n) | SetMaxOpenConns | The hard ceiling. Sum this across every instance of your app and keep the total below the server's connection limit (minus headroom for migrations, admin sessions, and other services). |
WithMaxIdleConns(n) | SetMaxIdleConns | Setting it equal to MaxOpenConns avoids churn — connections are reused instead of closed and reopened under steady load. |
WithConnMaxLifetime(d) | SetConnMaxLifetime | Recycles connections periodically. Essential behind load balancers and proxies that silently drop long-lived connections; also lets the pool rebalance after a database failover. |
WithConnMaxIdleTime(d) | SetConnMaxIdleTime | Shrinks the pool during quiet periods. |
There is no magic number for MaxOpenConns. Start around 25 per instance,
watch connection wait times under real load, and adjust. A pool much larger
than the database can serve concurrently just moves queueing from your app
into the server.
quark.New opens the connection and pings it with a five-second timeout, so a
misconfigured DSN fails at startup, not on the first request.
If you configure read replicas, each replica gets its own pool with the same options as the primary — budget server connections accordingly.
Set query timeouts
Every Quark read and write runs under a context timeout taken from
Limits.QueryTimeout — 30 seconds by default. Tighten it to match your
latency budget:
limits := quark.DefaultLimits()
limits.QueryTimeout = 5 * time.Second
client, err := quark.New("pgx", dsn, quark.WithLimits(limits))
Two things to know:
WithLimitsreplaces the entireLimitsstruct. Always start fromquark.DefaultLimits()and override fields. A partial literal has its numeric zeros filled in from the defaults, but its booleans do not — soSafeMigrationssilently lands onfalse, which permits a destructiveSync. See Zero-value semantics.- The timeout cancels the Go-side wait. Whether the statement also stops on
the server depends on the driver and engine; for defense in depth, set a
server-side statement timeout too (for example PostgreSQL's
statement_timeout).
The other Limits fields (MaxResults, MaxJoins, MaxWhereConditions,
MaxQueryLength) are guardrails against runaway queries; the
Configuration Reference documents each default.
Encrypt the wire: DSNs per engine
Quark passes the DSN straight to your database/sql driver — the driver owns
the format, so TLS is configured the way each driver expects:
| Engine | Example production DSN |
|---|---|
| PostgreSQL | postgres://app:secret@db.internal:5432/app?sslmode=verify-full |
| MySQL | app:secret@tcp(db.internal:3306)/app?parseTime=true&tls=true |
| MariaDB | Same DSN format and driver as MySQL; Quark detects MariaDB automatically. |
| SQL Server | sqlserver://app:secret@db.internal:1433?database=app&encrypt=true |
| Oracle | oracle://app:secret@db.internal:1521/APPPDB — TLS and wallet settings are go-ora connection options; see that driver's documentation. |
| SQLite | A file path. No network, no TLS — protect the file with filesystem permissions instead. |
Notes that bite in production:
- PostgreSQL:
sslmode=requireencrypts but does not verify the server's identity. Useverify-fullwith the CA certificate available to the client whenever you can. - MySQL / MariaDB:
tls=trueverifies against the system CA pool. For a private CA or client certificates, register a custom TLS config withmysql.RegisterTLSConfigand reference it by name in the DSN. KeepparseTime=true— you need it fortime.Timescanning regardless of TLS. - Never commit DSNs with credentials to source control — see Security for how to inject them.
Read replicas and failover
If your workload is read-heavy, WithReplicas routes reads across replica
pools while writes stay on the primary. A replica that fails with a transient
connection error is taken out of rotation for a cooldown and the read fails
over to the primary — a downed replica degrades performance, not correctness.
Replication lag is real, though: reads that must observe a just-committed
write need quark.Sticky(ctx).
The Read replicas guide covers routing rules, selection strategies, and consistency in full.
Migrations at deploy time
Run migrations as a separate deploy step — before the new application version starts serving traffic — not from your request path.
Two mechanics matter:
Migrations live in your binary, not in the standalone CLI. Versioned
migration files register themselves in an init(), so a go installed quark
binary cannot see them. quark migrate up exits non-zero with an explanation
rather than falsely reporting "nothing pending".
Build the small runner described in
Operational Workflows
— your main imports your migrations package and calls commands.Main(), which
prints errors to stderr and exits non-zero on failure — then ship that binary, or
go run it, in your deploy pipeline. For the model-diff flow, the
quarkmigrate package
gives you plan / verify / apply as a small command you own. verify exits
non-zero on schema drift, which makes it a natural CI gate.
Concurrent deploys must not race. If several instances can run migrations at once (rolling deploys, multiple replicas of a job), take the cluster-wide advisory lock first:
lock, err := client.AcquireMigrationLock(ctx, "schema-migrations", 30*time.Second)
if err != nil {
return err // quark.ErrLockTimeout if another deploy holds it
}
defer lock.Release(ctx)
The lock uses each engine's native primitive (PostgreSQL pg_advisory_lock,
MySQL/MariaDB GET_LOCK, SQL Server sp_getapplock, Oracle DBMS_LOCK —
which requires GRANT EXECUTE ON DBMS_LOCK). SQLite has no cross-process
lock primitive and returns quark.ErrUnsupportedFeature. Details in
Coordinating concurrent deploys.
Keep Limits.SafeMigrations at its default (true) in production: schema
sync then never drops columns it no longer recognizes.
Health checks
Quark does not ship a health endpoint, and doesn't need to: the underlying
*sql.DB is available via client.Raw(), and its PingContext is the
standard liveness signal.
func readyz(client *quark.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := client.Raw().PingContext(ctx); err != nil {
http.Error(w, "database unreachable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
}
The ping checks the primary connection. Replica health is managed internally (failed replicas go into cooldown and reads fail over to the primary), so a healthy primary is the right readiness signal even with replicas configured.
On shutdown, call client.Close() — it closes the primary pool and every
replica pool, and reports any close error rather than swallowing it.
See what production is doing
Three layers, all opt-in, all covered in depth in Caching and Observability:
- Structured logging — pass your
sloglogger withWithLogger. Quark logs operational warnings (safe-default row caps, deadlock retries, replica cooldowns) through it, in your format and at your level. - Slow-query log —
quark.WithSlowQueryThreshold(100*time.Millisecond)emits a WARN with duration, operation, table, row count, and the parameterized SQL for anything slower. Bind arguments are never logged — see Security for the exact redaction rules. - OpenTelemetry — add the
quarkotelmiddleware for a span per operation, andWithQueryObserverfor your own metrics (counters, histograms) from the same per-query events.
A reasonable production baseline:
client, err := quark.New("pgx", dsn,
quark.WithMaxOpenConns(25),
quark.WithMaxIdleConns(25),
quark.WithConnMaxLifetime(30*time.Minute),
quark.WithLimits(limits),
quark.WithLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil))),
quark.WithSlowQueryThreshold(100*time.Millisecond),
quark.WithMiddleware(quarkotel.New(quarkotel.WithDBSystem("postgres"))),
)