Application configuration, a simple approach.
A quick post about setting up app configuration using environment variables (or files).
Why?
There are the the standard reasons of avoiding committing production secrets in code, like Rabbit R1. However, I’ve come across more projects that do rely on .env
files, but simply haven’t documented what the .env
should look like, or where to get API keys, database connection strings, etc. Because it’s gitignored, it’s a puzzle to figure out what goes in it.
I’m writing this so I can signpost developers to a simple, maintainable way to set up configuration in their projects. I’d love your feedback would gladly update it if you’ve got better ideas.
Benefits of my approach
Keeps project configuration maintainable. You get a clear error immediately when the app starts, instead of getting confused by weird application or library behaviour because a string was empty later. You have up to date configuration documentation.
Bonus: You can also add additional error messages and documentation when the configuration is wrong or out of date, to help migrate existing developers local .env
files when you make changes to it.
Environment variables
We can configure variables in .env
files, and use libraries to automatically read and load text from them. Production applications would have the environment variables setup through a secrets manager or a web UI. Local application development would rely on local, .env
files.
Git ignored .env
You have 2 files: .env
(git ignored) and example.env
(not gitignored), which have identical structure. Every variable in the file contains a comment / documentation on how to set it up. For example:
# REMINDER: Update the `example.env` file when you update `.env`
# Database
# These credentials already match the local development database in docker (docker-compose.yml)
DATABASE_URL=postgres://user:[email protected]:5432/appname
## Staging (Supabase) - SSL/TLS is required by configuring this in the Supabase project settings
#DATABASE_URL=
# Production (Another DB service) - add some useful links here
#DATABASE_URL=
In the README, one of the setup steps would be: “Create a .env file from the template cp example.env .env
and update according to the comments”. This keeps documentation of environment variables in the same place where it gets changed, and avoids duplication of documentation in README files (easily gets out of date).
Committed .env
+ git ignored .env.local
This is a similar approach, but I don’t like it because IDEs don’t automatically syntax highlight this file, since it ends with .local
.
Validation
I validate environmnet variables, and I do that at application start-up time because I prefer the peace of mind that all the required API keys, backend URLs, database connection strings, etc. are already set correctly anywhere in my application.
I’ll share some simplified examples.
Typescript example
I use zod types. I can import env
anywhere else in the application.
import { z } from "zod";
const envSchema = z.object({
LOG_DATABASE: z.coerce.boolean().default(false),
OPENAI_API_KEY: z.string(),
DATABASE_URL: z
.string()
.describe(
[
"Connection string to postgres database, starting with `postgres://`.",
"Can be local or remote. Get the remote database from your project.",
].join(" "),
),
PORT: z
.string()
.describe(
[
"The port the backend application should listen on.",
"Useful when hosting on services that want to configure which",
"port is listened on, like Fly.io. Defaults to 4000.",
].join(" "),
)
.optional(),
});
// Load environment variables just before reading and validating them
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dotenvPath = path.resolve(__dirname, "../.env");
dotenv.config({ path: dotenvPath });
export const env = envSchema.parse(process.env);
Python example
I use pydantic.
from pydantic import BaseModel
from dotenv import load_dotenv
import os
load_dotenv()
class Config(BaseModel):
LOG_DATABASE: bool = False
DATABASE_URL: str
PORT: int
config = Config(**os.environ)
Bonus Python recommendation: I use uv by Astral, instead of Poetry, pdm, pyenv or anything else. It seems to have simplified python and python package management.
Configuration files
For more complicated projects and special cases, I use yaml config files. This isn’t an option if you can’t easily swap out the config files. For example, on Fly.io, it’s really easy to change environment variables and restart the application, but replacing the config file requires a new deploy. However, if you’re using Kubernetes/Helm, then yaml files fit in well.
The validation above can be used directly on the data generated from config files, including easily adding nested objects. I prefer yaml because it’s easier to read and edit, but you can use JSON or XML too.
Summary
I usually go with the environment variables + validation in my side projects because I don’t need complex structures available in yaml/json files.