Guide: configuration

tanGO's configuration surface is deliberately small:

type Config struct {
	InstalledApps   []App
	Addr            string
	Middleware       []Middleware
	MiddlewareScope  MiddlewareScope
	NotFound         View
	MethodNotAllowed View
}
  • InstalledApps — the apps that make up your project, in the order they should register. Order is significant: an app's Register may depend on state an earlier app already contributed (for example, the admin app in examples/api-with-admin reads registry.Admin() registrations that the posts app must have already added). InstalledApps is always set by hand in Go code — there is no config file or dynamic discovery.
  • Addr — the address http.ListenAndServe binds to when your main.go starts the server.
  • Middleware — raw net/http middleware applied globally to every compiled route, including routes contributed by reusable apps such as admin. The first entry is outermost. See routing and reverse lookup.
  • MiddlewareScope — what Middleware wraps. See middleware scope.
    • tango.MiddlewareScopeRoutes wraps each matched route. An Unmatched request, one the router answers itself with 404 or 405, passes no global middleware and is neither logged nor counted.
    • tango.MiddlewareScopeAll wraps the whole router, so Unmatched requests are logged, counted, recovered and limited like any other.
    • The zero value, tango.MiddlewareScopeDefault, means the framework's default, currently MiddlewareScopeRoutes. Projects created by tango newproject set MiddlewareScopeAll. Set a scope explicitly to keep it if the default ever changes, which can only happen after a minor release's notice.
  • NotFound, MethodNotAllowed — optional Views that answer Unmatched requests in place of the router's responses. See custom 404 and 405 pages.

LoadConfigFromEnv

func LoadConfigFromEnv(opts ...ConfigOption) Config

Returns a Config with Addr read from the TANGO_ADDR environment variable, defaulting to :8000 if unset. InstalledApps is never populated from the environment; your project composition stays visible in Go.

config := tango.LoadConfigFromEnv()
config.InstalledApps = []tango.App{posts.New(store)}

On a hosting platform that tells the app where to listen through PORT (Railway, Heroku, Cloud Run and others), pass tango.WithPortFromEnv():

config := tango.LoadConfigFromEnv(tango.WithPortFromEnv())

The address is then TANGO_ADDR if it's set, else ":"+PORT if PORT is set, else :8000. Without the option, PORT is ignored.

Production defaults

A project generated by tango newproject starts with these defaults; an older project can adopt each one:

  • Listening address: tango.LoadConfigFromEnv(tango.WithPortFromEnv()) listens on TANGO_ADDR, else the PORT a hosting platform sets, else :8000 (see LoadConfigFromEnv).
  • Request-header timeout: ServeContext drops a client that takes more than 10 seconds to send a request's headers, so a slow client can't hold connections open; tango.WithReadHeaderTimeout changes it (see request header timeout).
  • Database connection pool: ServeContext keeps up to 32 idle database connections for reuse (database/sql keeps 2, so a busy server closed and reopened a connection on almost every request); see database connection pool.
  • Body limit: the scaffold caps request bodies at 1 MiB with tango.MaxBodySize(1 << 20) (see request body limits).
  • Unmatched requests observed: with config.MiddlewareScope = tango.MiddlewareScopeAll, requests no route answers pass RequestID, Recoverer and AccessLogger too, logged and counted under the route "(unmatched)" (see middleware scope).

Database connection pool

ServeContext raises the idle connection limit of the *sql.DB you pass it to 32, so concurrent requests reuse connections instead of opening a new PostgreSQL session for each. On MySQL the default pool also reuses a connection for at most five minutes, because MySQL servers and proxies close idle connections; set MaxLifetime yourself to change that. The number of open connections stays unlimited, as in database/sql, and a pool you bounded yourself with SetMaxOpenConns is left as you set it. The default cannot tell that you set only SetConnMaxLifetime or SetConnMaxIdleTime, so to keep those, bound the pool with SetMaxOpenConns too or pass tango.WithDBPool.

tango.WithDBPool(tango.DBPool{...}) sets the limits explicitly (MaxOpen, MaxIdle, MaxLifetime, MaxIdleTime); a zero field is not touched, so tango.WithDBPool(tango.DBPool{}) keeps the plain database/sql behavior. Keep MaxOpen below your database's connection limit (PostgreSQL defaults to 100), counting every instance of the app.

Choosing MaxOpen

Left unlimited, the pool opens as many connections as there are requests in flight, so that many queries run at once. When the database has few CPUs they share those CPUs and finish together, and some requests wait much longer than the typical one. Measured with a PostgreSQL limited to 2 CPUs, 32 concurrent clients and a list endpoint that also counts the 10,000 matching rows: the 95th percentile latency was about 62 ms with an unbounded pool, about 51 ms with 6 connections and about 9 ms with 2, while throughput stayed the same or rose slightly (the typical request took 3.5 ms unbounded and 7.7 ms with 2).

A reasonable start is a MaxOpen near the number of CPUs your database has, raised only when the database sits idle while requests wait for a connection. When the limit is reached a request waits for a free connection, or gives up when its context ends. The right value depends on your queries and your hardware, so measure with your own.

Graceful shutdown needs one more line in main.go: serve with tango.ServeContext under signal.NotifyContext, as the examples do (see the application lifecycle guide).

Database env helpers

The scaffold also uses small standalone helpers, not a separate config language:

  • TANGO_DB_DSN via tango.LoadDBConfigFromEnv(), which parses it with db.ParseDSN and returns a db.DSN: the Dialect, plus the Driver name and Source string to pass to sql.Open. It defaults to sqlite://app.db when unset.

The DSN's scheme picks the database:

TANGO_DB_DSN Meaning
unset sqlite://app.db
sqlite://app.db SQLite file relative to the working directory
sqlite:///var/data/app.db SQLite, absolute path
sqlite://:memory: SQLite, in-memory
postgres://… / postgresql://… PostgreSQL; passed to pgx unchanged
mysql://user:pass@host:3306/db MySQL 8.4+; converted to the go-sql-driver/mysql form

Anything else, including a bare app.db, fails at startup with an error naming these forms. For SQLite the returned Source already enables foreign key enforcement (db.SQLiteForeignKeysDSN) and sets a 5-second busy timeout, so a write that finds another connection's write lock held waits for it instead of failing with database is locked (SQLITE_BUSY). A DSN that sets its own (sqlite://app.db?_pragma=busy_timeout(1000)) keeps it. The app still imports and registers its own driver: modernc.org/sqlite for SQLite, github.com/jackc/pgx/v5/stdlib for PostgreSQL, github.com/go-sql-driver/mysql for MySQL. There is no separate dialect variable; setting the retired one is a startup error telling you to put the scheme in TANGO_DB_DSN.

Admin credentials don't go through an env var at all — accounts live in the database, managed by the tango admin CLI (see admin registration).

tango.LoadEnvFile(".env") can load simple KEY=VALUE lines before those helpers run. Variables already set in the process environment win over .env, so deployment config can override local defaults.

View this page's source on GitHub