Environment variables in Go: os.Getenv and .env
Go reads environment variables with os.Getenv and os.LookupEnv and has no .env loader. Empty versus unset, godotenv's order, and running Go with Crunu.
Part of the guide
GoAt a glance
- Env files, highest priority first
- No .env files: values come from the process environment or the platform.
- Public prefix
- None
No names are copied into browser code by a prefix.
- Values applied
- At runtime
Values are read from the environment when the app runs.
- Built-in secret store
- None: the os package reads the process environment
- Crunu command
crunu run --env api/development -- go run ./cmd/apiSee the systemd integration
Common pitfalls
- os.Getenv returns an empty string both for an unset variable and for one set to empty. Use os.LookupEnv to tell them apart.
- Go reads no .env file. godotenv.Load never overwrites a variable that is already set in the environment.
- godotenv.Load with several files keeps the first value it finds, so the file that should win must come first.
- os.ExpandEnv replaces references to undefined variables with an empty string instead of failing.
- GOPRIVATE, GOFLAGS and SSH_AUTH_SOCK from your shell don't reach go under crunu run, which can break private module downloads.
On this page
Go reads environment variables only from the process environment, through the os package: os.Getenv("NAME") returns a value, os.LookupEnv("NAME") also tells you whether the variable exists, and os.Environ() lists everything. The standard library has no .env support and no file precedence. Whatever started the process decides what the program sees, and the values don't change while it runs. For .env files, Go projects add a library such as godotenv, which by default never overwrites variables that are already set. Function behavior below is quoted from the os package documentation for Go 1.27.3, checked October 30, 2026.
The os functions and what they return
| Function | Returns | Watch out for |
|---|---|---|
os.Getenv(key) | the value, or "" | an empty value and an unset variable look the same |
os.LookupEnv(key) | the value and true, or "" and false | nothing; prefer it for required settings |
os.Environ() | a copy of all key=value strings | logging it prints every secret |
os.ExpandEnv(s) | s with $var and ${var} replaced | undefined names become "" silently |
os.Setenv, os.Unsetenv, os.Clearenv | change this process's environment | children started later inherit the change |
The first row is the classic Go configuration bug. port := os.Getenv("PORT") gives "" when someone forgot to set PORT and also when they set it to nothing on purpose. Code then starts with an address that has no port, or connects with an empty password, and the failure appears far from its cause. Write a small helper on top of os.LookupEnv that fails at startup when a required variable is missing, and read every setting through it.
os.ExpandEnv has the same blind spot. A config template with ${DATABASE_URL} expands to an empty string when the variable is missing, and the error shows up later as a confusing connection failure. Check for missing names first if you rely on expansion.
Reading configuration once
Go programs usually read their environment into a typed struct in main and pass it down, rather than calling os.Getenv deep inside packages. This has practical benefits for secrets:
- One list of variables. The struct is the documentation of what the service needs, and it's easy to compare with your
.env.example. - Validation in one place. Parse durations, ports and booleans once, with clear errors, instead of at first use.
- No accidental logging. A type for secret fields with its own
String()method that prints a placeholder keeps values out of%voutput. - Easier tests. Tests construct the struct directly instead of mutating the process environment, which other tests running in parallel would see.
godotenv and its order
godotenv describes itself as a Go port of Ruby's dotenv. Its README states the key rule plainly: existing environment variables take precedence over variables loaded later. In practice that means:
godotenv.Load()reads.envand skips every name that's already set. A value exported in your shell, set by systemd or passed bycrunu runwins.godotenv.Load(".env.local", ".env")keeps the first value it finds. Pass the file that should win first. Writing them in "override order", base file first, gives you the opposite of what you intended.godotenv.Overload()does overwrite existing variables. The README asks you to use it with caution, because it lets a file on disk silently replace values set by the platform.
The library also has a command mode that loads a file and runs another program, which by default doesn't override existing variables either.
Child processes
exec.Command starts a child with the parent's whole environment unless you set the command's Env field: the os/exec documentation says that if Env is nil, the new process uses the current process's environment. A Go service that shells out to an image tool or a backup script therefore hands it every secret it has. Set Env to the few variables the child needs, keeping PATH, so a compromised or chatty helper can't leak the rest.
Run Go with values from Crunu
crunu run decrypts a Crunu environment in memory and starts your program with those variables in its process environment, which is the only place Go looks. No library is needed, and if you keep godotenv for local defaults, the Crunu values still win because godotenv doesn't overwrite them.
- Sign in once with
crunu login, then run the service during development withcrunu run --env api/development -- go run ./cmd/api. - Run integration tests against their own environment with
crunu run --env api/test -- go test ./.... - Build the binary without any secrets, since Go doesn't need them at compile time:
go build -o bin/api ./cmd/api. - On a Linux server with the agent, start it with
crunu run --env api/production --restart on-change -- /opt/api/bin/api.
Step 3 is a quiet advantage of Go. The compiler doesn't read your environment, so CI can build and test without production values, and only the server that runs the binary ever receives them. With --restart on-change, crunu sends SIGTERM when a value changes, waits for the program to exit, and starts it again. Handle SIGTERM with signal.NotifyContext and shut the HTTP server down gracefully so requests in flight complete.
The Go toolchain itself reads environment variables. crunu run passes only a short allowlist from your shell, including PATH and HOME, so go still finds its cache under your home directory. GOPRIVATE, GOFLAGS, GOPROXY and SSH_AUTH_SOCK are not passed. If go run needs to download a private module, add those to the Crunu environment or use --inherit-env for development.
Go in containers
Go services often ship in minimal images. crunu is a static binary for Linux amd64 and arm64, so it can be copied into the final stage of a multi-stage build next to your own binary. Two things are easy to forget: the final image needs CA certificates, or crunu can't verify the server's certificate and tells you which package is missing, and the install script needs sh plus curl or wget, so run it in an earlier stage. Inside the container, crunu works with a service token in CRUNU_TOKEN, signing in and decrypting inside its own process with no state files. Whoever stores that token can decrypt the environment it reads, so give it one environment only. See Docker Compose and Fly.io for platform specifics.
Related reading
- Node.js, the other runtime that reads only what it's given.
- Spring Boot for a typed, layered configuration system.
- Secrets on servers without files on disk and the systemd integration.
- How to share .env files with your team.
Frequently asked questions
Something else? Write to support@crunu.com
Is there a standard .env package in Go?
No. The standard library only reads the process environment. godotenv, a port of Ruby's dotenv, is a common third-party choice, and many Go services skip files and expect real environment variables.
How do I tell an unset variable from an empty one?
Use os.LookupEnv. It returns the value and a boolean that is false when the variable isn't present, while os.Getenv returns an empty string in both cases.
Can I run crunu in a scratch or distroless image?
crunu is a static binary, so it can be copied into a minimal image, but the image also needs CA certificates so crunu can verify the server's TLS certificate. The install script itself needs sh and curl or wget, so run it in a build stage.
Does a running Go service see a changed variable?
No. The environment is set when the process starts. Restart it, which crunu run does automatically with --restart on-change.
Sources
Every source this page relies on, with the date we last checked it.
- Go standard library: package os · checked October 30, 2026
- Go standard library: package os/exec · checked October 30, 2026
- godotenv README · checked October 30, 2026