Configuration
Configuration is application code in the config package. It reads the process environment into typed structs, validates each one at startup, and fails fast with an error that names the section.
How configuration loads
Each process entry loads .env and then builds the configuration it needs:
1// cmd/app/main.go
2if err := env.Load(); err != nil {
3 return err
4}
5
6cfg, err := config.New()
7if err != nil {
8 return err
9}
env.Load()readsKEY=VALUEpairs from.envand sets them in the process environment. Keys already present in the process environment win, so a deployment secret overrides the file. A missing.envis not an error; required keys then have to come from the real environment.config.New()calls one constructor per section (NewApp,NewHTTP,NewAuth,NewSession,NewCache,NewDatabase,NewQueueInsert,NewQueueWorker,NewTelemetry,NewMail,NewInertia) and returns oneconfig.Configvalue.- Every constructor reads with
pkg/env, validates, and wraps its failures asconfig: <section>: .... Malformed values are startup errors, not request-time surprises. cmd/migrateandcmd/seedscall onlyconfig.NewDatabase(). They do not need HTTP, session, mail or Inertia settings.- Nothing outside
config/reads environment variables.internal/runtimereceives typed values and passes them down.
Application and HTTP
| Variable | Default | Meaning |
|---|---|---|
ENVIRONMENT |
development |
Runtime policy; production enables secure cookies, production logging and the Mailpit guard |
PROJECT_NAME |
scaffold project name | Application identity and the fallback telemetry name |
SITE_NAME |
Andurel |
Display name shared with Inertia pages |
HOST_PRIMARY |
required | Public hostname, optionally with a port; the primary route host |
PROTOCOL |
http when empty |
Public scheme used to build URLs |
HOST |
localhost |
Listen address |
PORT |
8080 |
Listen port, 1 through 65535 |
HTTP_IDLE_TIMEOUT |
120s |
Keep-alive idle bound; zero disables it |
HTTP_READ_TIMEOUT |
10s |
Request-read bound; zero disables it |
HTTP_WRITE_TIMEOUT |
30s |
Response-write bound; zero disables it |
CORS_ALLOWED_ORIGINS |
empty | Comma-separated additional credentialed CORS origins |
CSRF_STRATEGY |
header_only |
Or header_or_legacy_token for form-token clients |
CSRF_TRUSTED_ORIGINS |
empty | Additional comma-separated trusted origins |
HTTP_TRUSTED_PROXIES |
empty | IPs or CIDRs of reverse proxies whose X-Forwarded-For is believed |
Configured host origins (PROTOCOL + HOST_PRIMARY and any named hosts) are trusted for CORS and CSRF automatically. Behind a proxy, a normal split is HOST_PRIMARY=app.example.com, PROTOCOL=https, HOST=0.0.0.0, and an internal PORT.
Database
| Variable | Default | Meaning |
|---|---|---|
DB_KIND |
postgres |
Database engine name |
DB_HOST / DB_PORT |
127.0.0.1 / 5432 |
PostgreSQL address |
DB_NAME |
project database | Database name |
DB_USER / DB_PASSWORD |
postgres / postgres |
Credentials |
DB_SSL_MODE |
disable |
pgx SSL mode, up to verify-full |
DB_APPLICATION_NAME |
project database | PostgreSQL activity identity |
DB_CONNECT_TIMEOUT |
5s |
Initial connection timeout |
DB_STATEMENT_CACHE_CAPACITY |
512 |
Statement-cache entries; zero disables caching |
DB_DESCRIPTION_CACHE_CAPACITY |
512 |
Description-cache entries; zero disables caching |
DB_MAX_OPEN_CONNECTIONS |
25 |
Open pool limit |
DB_MAX_IDLE_CONNECTIONS |
25 |
Idle pool limit |
DB_CONNECTION_MAX_LIFETIME |
1h |
Reusable connection lifetime |
DB_CONNECTION_MAX_IDLE_TIME |
30m |
Idle connection lifetime |
DB_OPEN_TELEMETRY |
true |
pgx instrumentation |
Budget connections across every web and queue replica, leaving room for migrations and administration. See Database.
Sessions and authentication
| Variable | Default | Meaning |
|---|---|---|
SESSION_KEY |
required | Hexadecimal cookie authentication key |
SESSION_ENCRYPTION_KEY |
required | Hexadecimal cookie encryption key |
SESSION_MAX_AGE |
604800 |
Session lifetime in seconds; must be positive |
TOKEN_SIGNING_KEY |
required | Account-token signing key |
PEPPER |
required | Current password pepper |
PREVIOUS_PEPPERS |
empty | Comma-separated peppers accepted during a rotation window |
Production values belong in deployment secrets, never in a committed .env. The cookie name is derived as app_sess_<project-slug>-<environment>, so environments on the same hostname do not share sessions. Rotating the session keys invalidates live cookies; rotating PEPPER moves the old value into PREVIOUS_PEPPERS until users have signed in again. See Authentication.
Cache
| Variable | Default | Meaning |
|---|---|---|
CACHE_DRIVER |
memory |
memory or redis |
CACHE_NAMESPACE |
project name | Key prefix shared by replicas |
CACHE_REDIS_ADDR |
empty | Required when the driver is redis |
Rate-limit counters live in this store, so a multi-replica deployment needs the Redis driver for shared limits.
Queue
Both queue configs start from storage.DefaultQueueConfig(). Insertion reads QUEUE_MAX_ATTEMPTS and QUEUE_SCHEMA; the worker clones that base and adds processing policy:
| Variable | Default | Meaning |
|---|---|---|
QUEUE_MAX_WORKERS |
100 |
Concurrency for the default queue |
QUEUE_ADVISORY_LOCK_PREFIX |
0 |
River lock namespace |
QUEUE_CANCELLED_JOB_RETENTION_PERIOD |
24h |
Cancelled-job retention |
QUEUE_COMPLETED_JOB_RETENTION_PERIOD |
24h |
Completed-job retention |
QUEUE_DISCARDED_JOB_RETENTION_PERIOD |
168h |
Discarded-job retention |
QUEUE_FETCH_COOLDOWN / QUEUE_POLL_INTERVAL |
River defaults | Fetch timing; polling cannot be shorter than the cooldown |
QUEUE_ID |
empty | Optional client identifier |
QUEUE_JOB_CLEANER_TIMEOUT |
30s |
Job-cleaner timeout |
QUEUE_JOB_STUCK_THRESHOLD / QUEUE_JOB_TIMEOUT |
River defaults | Stuck-job and execution bounds |
QUEUE_REINDEXER_TIMEOUT |
1m |
Reindex bound |
QUEUE_RESCUE_STUCK_JOBS_AFTER |
1h |
Must not be shorter than the job timeout |
QUEUE_SOFT_STOP_TIMEOUT |
River default | Shutdown grace period |
QUEUE_POLL_ONLY |
false |
Disable notification-assisted fetching |
QUEUE_SKIP_JOB_KIND_VALIDATION |
false |
Skip registered-kind validation |
QUEUE_SKIP_UNKNOWN_JOB_CHECK |
false |
Skip the stored-kind check |
Named queues and per-queue concurrency are code, not environment: edit NewQueueWorker in config/queue.go. See Queues.
Email and telemetry
DEFAULT_SENDER_SIGNATURE defaults to noreply@ plus the primary host, with any listen port removed. EMAIL_PROVIDER selects the transport:
mailpit(the default) sends to Mailpit onMAILPIT_HOST/MAILPIT_PORT(0.0.0.0/1025) and is refused in production.smtpusesSMTP_HOST,SMTP_PORT(default587),SMTP_USERNAME,SMTP_PASSWORD, andSMTP_TLS(starttls,tls, ornone).
| Telemetry variable | Default |
|---|---|
LOG_LEVEL |
INFO |
TELEMETRY_SERVICE_NAME |
slugged project name |
TELEMETRY_SERVICE_VERSION |
1.0.0 |
OTLP_LOGS_ENDPOINT, OTLP_METRICS_ENDPOINT, OTLP_TRACES_ENDPOINT |
empty |
OTLP_HEADERS |
empty |
TRACE_SAMPLE_RATE |
1.0, valid from 0 through 1 |
TELEMETRY_BATCH_SIZE |
512 |
TELEMETRY_BATCH_TIMEOUT_MS |
5000 |
Inertia and SSR
| Variable | Default | Meaning |
|---|---|---|
INERTIA_CONTAINER_ID |
app |
DOM element that mounts the page |
INERTIA_ENTRY_POINT |
resources/js/app.tsx |
Vite entry point |
INERTIA_VITE_DEV_URL |
http://localhost:5173/assets/dist |
Development asset base |
INERTIA_PROTOCOL_DEBUG |
true |
Log Inertia request classification and metadata keys |
INERTIA_SSR_RUNTIME |
node |
Managed SSR executable |
INERTIA_SSR_BUNDLE |
assets/dist/ssr/ssr.js |
Managed SSR bundle |
INERTIA_SSR_LISTEN |
http://127.0.0.1:13714 |
Address the SSR process binds |
INERTIA_SSR_URL |
http://127.0.0.1:13714 |
Address the app POSTs /render to |
INERTIA_SSR_STARTUP_TIMEOUT |
10s |
Managed process health deadline |
INERTIA_SSR_REQUEST_TIMEOUT |
2s |
Render deadline |
INERTIA_SSR_MAX_RESPONSE_BYTES |
2097152 |
Render response-size bound |
INERTIA_SSR_MINIMUM_MAJOR |
22 |
Oldest accepted Node.js major for managed SSR |
INERTIA_SSR_FAIL_FAST |
true |
Fail the page instead of falling back when SSR is unavailable |
SSR is opted into per page, not switched on globally. The shipped .env.example sets INERTIA_SSR_FAIL_FAST=false for local development, where no SSR process runs; a production deployment should decide the value explicitly. See Views.
Adding an application setting
Add a focused type, a constructor that reads and validates it, a field on config.Config, and a call from config.New:
1type Search struct {
2 Endpoint string
3 Timeout time.Duration
4}
5
6func NewSearch() (Search, error) {
7 e := env.New()
8 cfg := Search{
9 Endpoint: e.String("SEARCH_ENDPOINT", ""),
10 Timeout: e.Duration("SEARCH_TIMEOUT", 2*time.Second),
11 }
12 if err := e.Err(); err != nil {
13 return Search{}, fmt.Errorf("config: search: %w", err)
14 }
15 if cfg.Endpoint == "" {
16 return Search{}, fmt.Errorf("config: search: SEARCH_ENDPOINT is required")
17 }
18 if cfg.Timeout <= 0 {
19 return Search{}, fmt.Errorf("config: search: SEARCH_TIMEOUT must be positive")
20 }
21
22 return cfg, nil
23}
For a set of related fields, collect failures with validation.NewBuilder() and return b.Err() instead of testing each one by hand. Then construct the value in config.New, store it on config.Config, and pass it to the one dependency that needs it in internal/runtime. Copy maps or slices before another process mutates them; storage.QueueConfig.Clone is the pattern to follow.
Keep startup errors fatal. Continuing with a partial default turns a clear deployment failure into request-time corruption.