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'sRegistermay depend on state an earlier app already contributed (for example, the admin app inexamples/api-with-adminreadsregistry.Admin()registrations that thepostsapp must have already added).InstalledAppsis always set by hand in Go code — there is no config file or dynamic discovery.Addr— the addresshttp.ListenAndServebinds to when yourmain.gostarts the server.Middleware— rawnet/httpmiddleware 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— whatMiddlewarewraps. See middleware scope.tango.MiddlewareScopeRouteswraps each matched route. An Unmatched request, one the router answers itself with404or405, passes no global middleware and is neither logged nor counted.tango.MiddlewareScopeAllwraps 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, currentlyMiddlewareScopeRoutes. Projects created bytango newprojectsetMiddlewareScopeAll. 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 onTANGO_ADDR, else thePORTa hosting platform sets, else:8000(seeLoadConfigFromEnv). - Request-header timeout:
ServeContextdrops a client that takes more than 10 seconds to send a request's headers, so a slow client can't hold connections open;tango.WithReadHeaderTimeoutchanges it (see request header timeout). - Database connection pool:
ServeContextkeeps up to 32 idle database connections for reuse (database/sqlkeeps 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 passRequestID,RecovererandAccessLoggertoo, 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_DSNviatango.LoadDBConfigFromEnv(), which parses it withdb.ParseDSNand returns adb.DSN: theDialect, plus theDrivername andSourcestring to pass tosql.Open. It defaults tosqlite://app.dbwhen 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.