Configuration & Environment
This guide covers configuration in Tinka. You will learn about:
- Environment variables and the
.envfile - The
APP_KEYand how to generate it - Reading variables into your App at boot
- Variable interpolation within
.envfiles - Reading values from files, such as Docker secrets
- Environment-specific
.envfiles for development, testing and production - Validating environment variables with typed getters
- Writing
.envfiles from a Command
Overview
An App reads the settings that change between machines, such as its port, its URL and its API keys, from environment variables, so the same binary runs on your laptop, in tests and in production. The env package (github.com/mucolabs/tinka/env) reads the real environment and your .env files, converts each variable to the Go type your App needs, and collects every missing or invalid one. A misconfigured App stops at boot with the whole list of problems, instead of failing later on the first one it meets.
Environment variables
Environment variables hold secrets and settings that differ between environments. During development you keep them in a .env file, one KEY=value per line. In production you set them in your hosting platform's settings or your container definition.
HOST=localhost
PORT=3333
APP_URL=http://localhost:3333
APP_KEY=dev_only_key_1234567890
MAIL_FROM=hello@example.com
STRIPE_KEY=sk_test_1234567890The format is the one Node.js reads, so a .env file shared with Node tools means the same thing in both:
- lines starting with
#are comments, and so is anything after#in an unquoted value - spaces around the key and the value are ignored, and an
exportprefix is allowed - values in
'…',"…"or`…`keep their spaces and#characters and can span several lines, which suits certificates; inside"…",\nbecomes a newline - a key starts with a letter or
_, followed by letters, digits,_,.or-
Where Node guesses what a broken line means, Tinka reports it with its file and line instead: a line without =, an invalid key, an unclosed quote, and text after a closing quote.
The APP_KEY
APP_KEY is your App's secret key. Tinka's encryption package, which comes next, will use it to encrypt data and to sign cookies and URLs. Generate it with act generate:key, which writes a random 256-bit key to .env and adds an empty APP_KEY= to .env.example, so your team knows to set one without seeing yours:
act generate:keyWrote APP_KEY to .env and .env.exampleAnyone who has the key can read what it encrypted and forge what it signs, so keep it out of the repository and use a different key in each environment. Production reads no .env file, so when APP_ENV is production the Command prints the key instead of writing it; set it in your platform's environment. act generate:key --show prints a key in any Mode. If a key leaks, generate a new one: data encrypted or signed with the old key can no longer be read.
Using environment variables in your App
Read environment variables once, at boot, into a configuration struct, and pass that struct to the code that needs it. Code that calls os.Getenv deep inside a handler hides what the App depends on and fails long after startup. env.Load returns an Env, each getter reads one variable as a Go type, and Err reports every problem at once:
package main
import (
"net/url"
"time"
"github.com/mucolabs/tinka/env"
)
type Config struct {
Port int
AppURL *url.URL
AppKey env.Secret
ReadTimeout time.Duration
}
func loadConfig() (Config, error) {
e := env.Load()
cfg := Config{
Port: e.Int("PORT", 3333),
AppURL: e.URL("APP_URL"),
AppKey: e.Secret("APP_KEY"),
ReadTimeout: e.Duration("READ_TIMEOUT", 10*time.Second),
}
return cfg, e.Err()
}main stops the App when the configuration is wrong:
package main
import (
"fmt"
"os"
)
func main() {
cfg, err := loadConfig()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("listening on port", cfg.Port)
}Run it with an invalid port and no APP_URL or APP_KEY, and the error lists all three problems:
PORT=80a go run .env: 3 problems:
PORT must be a whole number, got "80a" (environment)
APP_URL is missing
APP_KEY is missingenv.Load never changes the process environment: the values it reads stay inside the Env.
Variable interpolation
A value in a .env file can use another variable with $NAME, which saves repeating a host or a port:
HOST=localhost
PORT=3333
APP_URL=http://$HOST:$PORTAPP_URL reads as http://localhost:3333. Wrap the name in braces when it contains a dash or touches other text:
BUCKET=blog
REGION=eu-west-1
ASSETS_URL=https://${BUCKET}.s3.${REGION}.amazonaws.comA reference can point to a variable in another .env file or in the real environment, which wins when both define it. A variable that isn't set anywhere expands to an empty string, and two variables that refer to each other are reported. Write \$ for a literal dollar sign, or single-quote the value, which is never expanded, as in a shell:
PRICE=\$99
PASSWORD='pa$$word'Reading values from files
Docker and Kubernetes mount secrets as files, so the secret never sits in the environment. When KEY is missing and KEY_FILE is set, the getter reads the value from the file KEY_FILE names:
DB_PASSWORD_FILE=/run/secrets/db_password
GCS_KEY_FILE=gcs_key.jsone.Secret("DB_PASSWORD") returns the file's contents, with one trailing newline dropped. A relative path is resolved against the directory the .env files are loaded from. A file that can't be read is reported at boot. KEY itself always wins over KEY_FILE.
An identifier resolves values that start with a prefix you choose, such as base64:. Pass env.Identifier to env.Load with the function that turns the rest of the value into the real one:
e := env.Load(env.Identifier("base64", func(v string) (string, error) {
b, err := base64.StdEncoding.DecodeString(v)
return string(b), err
}))
token := e.Secret("API_TOKEN") // API_TOKEN=base64:czNjcmV0 reads as s3cretAn identifier applies to values from the real environment and from the files alike. A value it can't resolve is reported at boot. Write base64\: to keep a value that starts with base64: as it is. Identifiers belong to the Env you pass them to, so two Apps in one test binary never share them.
Environment-specific .env files
The Mode says how an App runs: development, test or production. It comes from APP_ENV, defaults to development, and decides which .env files env.Load reads:
| File | Purpose | Git status |
|---|---|---|
| .env | Base variables for every Mode | Ignored |
| .env.local | Local overrides, loaded in every Mode except test | Ignored |
| .env.development | Variables for development | Ignored |
| .env.test | Variables for tests, loaded when APP_ENV=test | Ignored |
| .env.{mode}.local | Local overrides for one Mode, such as .env.test.local | Ignored |
| .env.example | Every variable the App reads, with placeholder values | Committed |
When a variable is defined in several files, the file with the higher priority wins. For APP_ENV=development, from the highest priority:
.env.development.local.env.local.env.development.env
For APP_ENV=test:
.env.test.local.env.test.env
.env.local isn't loaded in test, so your local overrides can't change what the test suite sees. A variable that's already in the real environment wins over every file, so your platform or CI can override a value without editing a file. A variable set to an empty value, such as SENTRY_DSN=, still replaces the same variable from a lower priority file, and then counts as missing.
APP_ENV itself must come from the real environment, because it decides which files to read; setting it in a .env file is reported. Read the Mode with e.Mode(), which returns env.Development, env.Test or env.Production.
env.Load reads the files from the working directory. Set ENV_PATH in the real environment to read them from another directory, resolved against the working directory:
ENV_PATH=config/env go run .In code, env.Dir sets the directory ENV_PATH is resolved against:
e := env.Load(env.Dir("deploy/local")).env.example documents the App's variables for your team. Commit it with placeholder values, so a new developer copies it to .env and fills in their own:
PORT=3333
APP_URL=http://localhost:3333
APP_KEY=your_app_key
MAIL_FROM=hello@example.com
STRIPE_KEY=your_stripe_test_keyValidating environment variables
Every getter both reads and validates. A getter without a fallback reads a required variable, and Err reports it when it's missing. Pass a fallback to make the variable optional; the getter returns the fallback when the variable is missing. An empty value counts as missing. A value that's present but invalid is always reported, even with a fallback, because it's a mistake you want to see.
e.Int("PORT") // required
e.Int("PORT", 3333) // optional, 3333 when missingEach getter converts to one type:
A problem quotes the rejected value so you can fix it, except from String, Secret and URL, which can hold credentials.
To require a variable in one Mode only, branch on the Mode:
dsn := e.Secret("SENTRY_DSN", "")
if e.Mode() == env.Production {
dsn = e.Secret("SENTRY_DSN")
}env.Map builds an Env from a map, without reading the process environment or any file. Tests that use it can run in parallel, which t.Setenv doesn't allow:
package main
import (
"testing"
"github.com/mucolabs/tinka/env"
)
func TestPortFallback(t *testing.T) {
t.Parallel()
e := env.Map(map[string]string{"APP_ENV": "test"})
if got := e.Int("PORT", 3333); got != 3333 {
t.Errorf("PORT = %d, want 3333", got)
}
}To test a function like loadConfig this way, have it take the Env as a parameter, and pass env.Load() from main.
Writing .env files from a Command
A Command that generates a value, such as a new APP_KEY, records it with an env.Editor. env.Edit opens .env and .env.example in the directory env.Load reads from, and Save writes them back with every other line, comment and blank line unchanged:
package commands
import "github.com/mucolabs/tinka/env"
// SaveKey records key as the App's APP_KEY.
func SaveKey(key string) error {
ed, err := env.Edit()
if err != nil {
return err
}
if err := ed.SetSecret("APP_KEY", key); err != nil {
return err
}
return ed.Save()
}Set writes the value to both files. SetSecret writes it to .env and leaves it empty in .env.example, so the example names the variable without revealing it. An existing key is updated in place and a new one is added at the end. The value is quoted when it needs to be, so env.Load reads it back exactly. A file that doesn't exist is never created; ed.Files() lists the ones found.
To read a .env file without loading it, env.Parse returns its variables with references between them expanded, and env.Paths lists the files env.Load reads for a Mode, from the highest priority.
See also: Commands, Installation