README
¶
go-toolkit
A collection of lightweight utility packages for Go. No external dependencies — standard library only.
Packages
| Package | Description |
|---|---|
env |
Environment variable loader with .env file support and typed getters. ErrNotSet separates an absent variable from one set to an unparseable value |
logger |
Structured logger built on log/slog with file and context support |
secrets |
Pluggable secret source with TTL caching and ordered fallback. Env and File sources |
Requirements
- Go 1.21+
Installation
go get codeberg.org/obadness/go-toolkit
env
import "codeberg.org/obadness/go-toolkit/env"
Loading a .env file
if err := env.Load(".env"); err != nil {
log.Fatal(err)
}
// Or, when the file is optional — typically outside development:
if err := env.LoadIfExists(".env"); err != nil {
log.Fatal(err)
}
Variables are loaded into the process environment via os.Setenv and are immediately available via os.Getenv or any of the helpers below. Variables already set in the environment are not overwritten — including those deliberately set to an empty value.
.env file format
# Comments are ignored
APP_NAME=myapp
PORT=8080 # trailing comments are stripped
DEBUG=true
ALLOWED_HOSTS=localhost,127.0.0.1
# Quoted values are supported
DB_URL="postgres://user:pass@localhost:5432/db"
SECRET='my secret value'
# A shell-sourceable file works too
export API_KEY=abc123
# A # with no whitespace before it belongs to the value
PASSWORD=p@ss#word
Supported syntax:
KEY=valueKEY="value"orKEY='value'— matching quotes are stripped, and whitespace inside them is preservedexport KEY=value— theexportprefix is ignored- Lines starting with
#are comments - Outside quotes, a
#preceded by whitespace starts a trailing comment; a#with no whitespace before it is part of the value - Blank lines are ignored
- Values may contain
=— only the first=splits the line - Lines up to 1 MB are supported, so a single-line PEM key or certificate loads fine
A malformed line — no =, an empty key, an unterminated quote, or stray text after a closing quote — is an error naming the file, line number and key.
Quotes do not nest, so a value containing an apostrophe must use double quotes:
NAME="it's fine" # correct
NAME='it's fine' # error: the quote closes after "it"
Helpers
Parse
Returns the value or an error if the variable is not set. A variable set to an empty value returns that empty value without an error. All typed getters are built on it.
dsn, err := env.Parse("DATABASE_URL")
if err != nil {
log.Fatal(err)
}
ParseInt
port, err := env.ParseInt("PORT")
ParseInt32
maxConn, err := env.ParseInt32("MAX_CONNECTIONS")
ParseInt64
maxSize, err := env.ParseInt64("MAX_FILE_SIZE")
ParseUint
workers, err := env.ParseUint("WORKERS")
ParseUint32
maxConn, err := env.ParseUint32("MAX_CONNECTIONS")
ParseUint64
maxSize, err := env.ParseUint64("MAX_FILE_SIZE")
ParseFloat64
rate, err := env.ParseFloat64("RATE_LIMIT")
ParseBool
Accepts 1, t, true, TRUE, 0, f, false, FALSE.
debug, err := env.ParseBool("DEBUG")
ParseDuration
Parses a Go duration string (e.g. 500ms, 30s, 1h).
timeout, err := env.ParseDuration("REQUEST_TIMEOUT")
ParseList
Parses a comma-separated variable into a string slice. Whitespace is trimmed.
// ALLOWED_HOSTS=localhost, 127.0.0.1, ::1
hosts, err := env.ParseList("ALLOWED_HOSTS")
// ["localhost", "127.0.0.1", "::1"]
Or — fall back to a default
Returns the fallback when the variable is unset. Works with any of the getters above.
port := env.Or(env.ParseInt, "PORT", 8080)
timeout := env.Or(env.ParseDuration, "TIMEOUT", 30*time.Second)
host := env.Or(env.Parse, "HOST", "0.0.0.0")
A variable that is set but cannot be parsed is a configuration error, not a missing value, and Or panics: PORT=8O80 with a letter O would otherwise be indistinguishable from PORT being absent. To handle a bad value instead of crashing, call the getter directly — each returns an error, and an absent variable is reported as env.ErrNotSet:
port, err := env.ParseInt("PORT")
switch {
case errors.Is(err, env.ErrNotSet):
port = 8080
case err != nil:
return fmt.Errorf("PORT: %w", err)
}
Must — fail fast
Panics when the variable is missing or invalid. For start-up configuration the program cannot run without.
dsn := env.Must(env.Parse("DATABASE_URL"))
port := env.Must(env.ParseInt("PORT"))
Environment helpers
env.IsDev() // true when APP_ENV or ENV is "development" or "dev"
env.IsProd() // true when APP_ENV or ENV is "production" or "prod"
env.IsTest() // true when APP_ENV or ENV is "test"
APP_ENV takes precedence over ENV, and the comparison is case-insensitive.
Notes
Loaddoes not overwrite variables already set in the environment.Loadcallsos.Setenv, which sets variables only for the current process.- In production (Docker, systemd, Kubernetes) you typically do not need
Load— inject variables directly via the runtime and use only the helper functions.
Typical setup
func main() {
if err := env.LoadIfExists(".env"); err != nil {
log.Fatal(err)
}
var (
dsn = env.Must(env.Parse("DATABASE_URL"))
port = env.Or(env.ParseInt, "PORT", 8080)
)
}
logger
Structured logger built on Go's standard log/slog. Supports text and JSON output, daily rotating log files, per-level file routing, and context propagation.
import "codeberg.org/obadness/go-toolkit/logger"
Logger embeds *slog.Logger, so the entire slog API is available on it.
Create a logger
// Text format — good for development, stderr only
log := logger.NewDefault()
// JSON format — good for production log aggregators, stderr only
log := logger.NewJSON()
// JSON + daily rotating per-level files + stderr
// Produces: logs/app_debug_20260117.log, logs/app_info_20260117.log etc.
log := logger.NewWithFile("./logs")
// JSON + single daily rotating file + stderr
// Produces: logs/myapp_20260117.log
log := logger.NewWithCombinedFile("./logs", "myapp")
// Full control
log := logger.New(logger.Options{
Level: logger.Debug,
JSON: true,
AddSource: true,
File: &logger.FileOptions{
Dir: "./logs",
Prefix: "myapp",
PerLevel: true,
},
})
Basic usage
log.Info("server started", slog.String("address", "localhost:8080"))
log.Debug("request received", slog.String("method", "GET"), slog.String("path", "/users"))
log.Warn("slow query", slog.Duration("duration", 2*time.Second))
log.Error("database error", slog.Any("error", err))
File output
When file logging is enabled every log entry is written to both the console and the log file. Files rotate daily — a new file is created automatically when the date changes.
Per-level files (PerLevel: true):
logs/
├── myapp_debug_20260117.log
├── myapp_info_20260117.log
├── myapp_warn_20260117.log
└── myapp_error_20260117.log
Combined file (PerLevel: false):
logs/
└── myapp_20260117.log
New never fails. If the log directory cannot be created or a file cannot be opened, the problem is reported on stderr — once per distinct error, not once per record — and logging continues to the console alone.
Old files are kept indefinitely. Pruning them is left to logrotate or an equivalent.
Close log files
Call Close() on application shutdown to close open log files:
log := logger.NewWithFile("./logs")
defer log.Close()
Close is safe to call more than once, and on a logger without file output. It is not final either — a logger written to after Close reopens its file rather than dropping the record.
Console output
Console output goes to os.Stderr unless Writer says otherwise. Set it to capture output in tests, or to io.Discard to write only to file:
var buf bytes.Buffer
log := logger.New(logger.Options{Level: logger.Info, Writer: &buf})
Attach fields to every log entry
log := logger.With(logger.NewJSON(),
slog.String("service", "fleet-api"),
slog.String("version", "1.0.0"),
)
Set as global default
log := logger.NewJSON()
logger.SetDefault(log)
// Now the global slog functions use this logger
slog.Info("server started")
slog.Error("something failed", slog.Any("error", err))
Context propagation
// Attach a request-scoped logger to context
ctx := logger.WithContext(ctx, logger.With(log,
slog.String("request_id", requestID),
slog.String("user_id", userID),
))
// Retrieve anywhere downstream
log := logger.FromContext(ctx)
log.Info("processing request")
FromContext returns a logger wrapping the slog default when the context carries none, so it is always safe to call.
Options
| Field | Type | Default | Description |
|---|---|---|---|
Level |
slog.Level |
logger.Info |
Minimum log level |
JSON |
bool |
false |
JSON output format |
AddSource |
bool |
false |
Include source file and line number |
Writer |
io.Writer |
os.Stderr |
Destination for console output |
File |
*FileOptions |
nil |
File logging — disabled if nil |
FileOptions
| Field | Type | Default | Description |
|---|---|---|---|
Dir |
string |
"./logs" |
Directory for log files |
Prefix |
string |
"app" |
File name prefix |
PerLevel |
bool |
false |
Separate file per log level |
Log levels
logger.Debug // slog.LevelDebug
logger.Info // slog.LevelInfo
logger.Warn // slog.LevelWarn
logger.Error // slog.LevelError
secrets
Fetches sensitive values from a pluggable source: environment variables in development, a managed secret store in production. Defines one interface so that swapping backends is a single line where the source is constructed.
import "codeberg.org/obadness/go-toolkit/secrets"
The interface
type Source interface {
Secret(ctx context.Context, key string) (string, error)
}
A source returns an error wrapping secrets.ErrNotFound when the key is absent, and a distinct error for transport or authentication failures. That distinction is the point: the first is a configuration error an operator can fix, the second is an outage worth retrying.
password, err := src.Secret(ctx, "DB_PASSWORD")
switch {
case errors.Is(err, secrets.ErrNotFound):
log.Fatal("DB_PASSWORD is not configured")
case err != nil:
log.Fatal("secret store unreachable: ", err)
}
Env — the default source
Reads from environment variables. The zero value is usable.
src := secrets.Env{}
// Or namespace the variables: "DB_PASSWORD" reads MYAPP_DB_PASSWORD
src := secrets.Env{Prefix: "MYAPP_"}
A variable set to an empty value is reported as ErrNotFound. This is a deliberate divergence from env.Parse, which treats an empty value as a value: a blank feature flag is meaningful, a blank signing key is not, and an application that boots with one is worse off than one that refuses to start.
Env does not read .env files — load one first with the env package, which keeps secrets free of file handling.
Cached — TTL caching
A decorator, so caching behaves identically whichever source is underneath.
// Cache forever — the right default for secrets read once at startup
src := secrets.NewCached(backend, 0)
// Or bound how stale a value can be after a rotation
src := secrets.NewCached(backend, 15*time.Minute)
// Drop one value when you learn out-of-band that it rotated
src.Invalidate("DB_PASSWORD")
- Only successful fetches are cached. A failure is never memoised, so a transient outage at boot cannot poison the cache for the lifetime of the process.
- Safe for concurrent use. Concurrent misses on the same key each reach the source; that duplication is accepted rather than adding a
singleflightdependency.
File — a secret per file
src := secrets.File{Dir: "/etc/myapp/secrets"}
tok, err := src.Secret(ctx, "TOKEN") // reads /etc/myapp/secrets/token
For a deployment that mounts credentials rather than passing them in the environment: a systemd credential, a Docker secret, a Kubernetes volume.
The key is lowercased to form the filename and may not contain a path separator, so it cannot escape Dir. Contents are trimmed, so the newline left by openssl rand -hex 32 > token does not become part of the value. A missing or empty file is ErrNotFound, matching Env.
A file readable by group or other is refused, not served — a secret anyone on the host can read has to be assumed leaked, and that error is deliberately not ErrNotFound, so a MultiSource stops rather than quietly falling through to a weaker source.
MultiSource — ordered fallback
Tries each source in order and returns the first hit. For a deployment that reads most secrets from a managed store but allows a local override.
src := secrets.MultiSource{secrets.Env{}, managedStore}
Only ErrNotFound advances to the next source. Any other error stops the chain, so an authentication failure cannot quietly fall through to a weaker source. An empty MultiSource returns ErrNotFound rather than an empty value.
Backends
AWS Secrets Manager and HashiCorp Vault backends live in their own module, so that go-toolkit stays free of SDK dependencies. They satisfy the same Source interface:
var src secrets.Source = secrets.Env{}
if env.IsProd() {
src = secrets.NewCached(awsbackend.New(client, awsbackend.WithPrefix("myapp/prod/")), 0)
}
Two rules
- Secrets only. A host or a timeout routed through a secret store is a wasted call and, against a paid API, a charge. Use
envfor plain configuration. The package does not enforce this. - Fetch at startup. Read secrets once into your config struct and hold them for the lifetime of the process. Do not call
Secret()per request.
Out of scope
- No
.envloading — that is theenvpackage's job. - No rotation handling beyond a TTL and
Invalidate. If a credential rotates, re-reading it and reconnecting is the caller's job. - No logging. The package never logs a fetched value, and errors name the key, never the value.
Consumer pattern
The config loader takes a secrets.Source and uses it only for sensitive values:
func Load(ctx context.Context, src secrets.Source) (Config, error) {
dbPassword, err := src.Secret(ctx, "DB_PASSWORD")
if err != nil {
return Config{}, err
}
jwtSecret, err := src.Secret(ctx, "JWT_SECRET")
if err != nil {
return Config{}, err
}
return Config{
DB: DB{
Host: env.Or(env.Parse, "DB_HOST", "localhost"), // not a secret
Name: env.Must(env.Parse("DB_NAME")), // not a secret
Password: dbPassword, // secret
},
Auth: Auth{JWTSecret: jwtSecret},
}, nil
}
Typical setup
import (
"codeberg.org/obadness/go-toolkit/env"
"codeberg.org/obadness/go-toolkit/logger"
)
func main() {
// Optional in production, where variables are injected by the runtime
if err := env.LoadIfExists(".env"); err != nil {
log.Fatal(err)
}
// Logger — JSON + file in production, text in development
var log *logger.Logger
if env.IsProd() {
log = logger.NewWithFile("./logs")
} else {
log = logger.NewDefault()
}
defer log.Close()
logger.SetDefault(log)
// Config — Must for what the program cannot run without, Or for the rest
var (
dsn = env.Must(env.Parse("DATABASE_URL"))
host = env.Or(env.Parse, "HOST", "0.0.0.0")
port = env.Or(env.ParseInt, "PORT", 8080)
)
log.Info("starting server",
slog.String("host", host),
slog.Int("port", port),
slog.Bool("prod", env.IsProd()),
)
}
Project structure
go-toolkit/
├── go.mod
├── env/
│ ├── env.go # Load, Parse, typed getters, Or, Must, IsDev, IsProd
│ └── env_test.go
├── logger/
│ ├── logger.go # New, NewDefault, NewJSON, NewWithFile, SetDefault, With, FromContext
│ └── logger_test.go
└── secrets/
├── secrets.go # Source, ErrNotFound, MultiSource
├── env.go # Env — environment variable source
├── cache.go # Cached — TTL caching decorator
└── *_test.go
Tests
go test ./...
go test -race -cover ./...
License
MIT — Copyright (c) 2026 Jeremy Obado. See LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package env loads environment variables from a .env file and reads them back as typed values.
|
Package env loads environment variables from a .env file and reads them back as typed values. |
|
Package logger wraps log/slog with console output, daily rotating log files and context propagation.
|
Package logger wraps log/slog with console output, daily rotating log files and context propagation. |
|
Package secrets fetches application secrets from a pluggable source: environment variables in development, a managed secret store in production.
|
Package secrets fetches application secrets from a pluggable source: environment variables in development, a managed secret store in production. |