Most applications do not need a clever configuration system.
They need a boring contract between code and the environment where the code runs.
That contract should answer a few simple questions:
- which values can change between deploys;
- where each value comes from;
- what type each value has;
- whether a value is required;
- how secrets are delivered;
- what happens when the config is invalid.
If those answers are scattered across framework defaults, shell scripts, Docker Compose files, example YAML, and tribal memory, the application does not have configuration. It has folklore.
What Belongs In Config
Configuration is the part of the application that changes between deploys while the code stays the same.
Good examples:
- database DSNs and resource handles;
- hostnames, ports, public base URLs;
- credentials and secret references;
- per-deploy feature flags;
- cache, queue, and storage endpoints;
- operational limits such as timeout values.
Bad examples:
- routes;
- class wiring;
- internal module names;
- business rules that are versioned with code;
- values that can be computed from other config values.
The difference matters. A route table belongs in code because changing it changes the application. A database DSN belongs in config because the same application can run against different databases in local, staging, and production.
Do Not Invent A Format
Custom config formats feel pleasant for the first afternoon and expensive for the next several years.
The parser is yours. The escaping rules are yours. The documentation is yours. The edge cases are yours. The migration path is yours.
Use a format that already has parsers and tooling:
- JSON when machines write it or strict syntax is useful;
- YAML when humans edit structured values and the project already uses YAML;
- TOML when you want a small typed config format;
- INI only for simple flat configuration;
- native language files only for internal code wiring, not deploy-specific values.
The exact format matters less than the contract around it. A validated JSON file is better than an unvalidated YAML file. A tiny environment-based setup is better than a beautiful config hierarchy nobody understands.
Environment Variables Are Transport
Environment variables are a good way to pass deploy-specific values into an application. They are language-agnostic, work naturally with containers, and keep configuration outside the build artifact.
They are not a type system.
Every environment variable arrives as text. Your application must parse it, validate it, and fail early when it is wrong.
This is the shape I want in PHP:
<?php
final readonly class AppConfig
{
public function __construct(
public string $appEnv,
public bool $debug,
public string $databaseDsn,
public int $httpPort,
public string $appSecret,
) {}
public static function fromEnvironment(): self
{
return new self(
appEnv: self::required('APP_ENV'),
debug: self::boolean('APP_DEBUG', false),
databaseDsn: self::required('DATABASE_DSN'),
httpPort: self::integer('HTTP_PORT', 8080, min: 1, max: 65535),
appSecret: self::secret('APP_SECRET', 'APP_SECRET_FILE'),
);
}
private static function required(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
throw new RuntimeException("Missing required config: {$name}");
}
return $value;
}
private static function boolean(string $name, bool $default): bool
{
$value = getenv($name);
if ($value === false || $value === '') {
return $default;
}
$parsed = filter_var(
$value,
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE,
);
if ($parsed === null) {
throw new RuntimeException("Invalid boolean config: {$name}");
}
return $parsed;
}
private static function integer(
string $name,
int $default,
int $min,
int $max,
): int {
$value = getenv($name);
if ($value === false || $value === '') {
return $default;
}
$parsed = filter_var(
$value,
FILTER_VALIDATE_INT,
['options' => ['min_range' => $min, 'max_range' => $max]],
);
if ($parsed === false) {
throw new RuntimeException("Invalid integer config: {$name}");
}
return $parsed;
}
private static function secret(string $name, string $fileName): string
{
$file = getenv($fileName);
if ($file !== false && $file !== '') {
$value = file_get_contents($file);
if ($value === false || trim($value) === '') {
throw new RuntimeException("Invalid secret file: {$fileName}");
}
return trim($value);
}
return self::required($name);
}
}
Then build the config once at startup:
$config = AppConfig::fromEnvironment();
Do not call getenv() from random services. Do not parse booleans in controllers. Do not let half the codebase decide what APP_DEBUG=yes means.
One loader. One typed object. One place to fail.
Defaults Are A Promise
Defaults are not harmless.
This is fine:
httpPort: self::integer('HTTP_PORT', 8080, min: 1, max: 65535)
A local HTTP port has a reasonable default.
This is not fine:
databaseDsn: getenv('DATABASE_DSN') ?: 'sqlite:///:memory:'
The application may start successfully against the wrong database. That is worse than failing.
Use defaults only when the default is safe, explicit, and unsurprising. Required production values should stop the process when missing.
Make Precedence Explicit
Every mature system eventually gets multiple config sources:
- code defaults;
.envfiles for local development;- shell environment;
- Docker Compose interpolation;
- orchestrator variables;
- secret files;
- command-line flags.
That is fine if precedence is documented and testable. It is dangerous when nobody knows which layer wins.
For Docker Compose, remember two separate ideas:
- interpolation fills values in
compose.yaml; environmentorenv_filesets variables inside the container.
An .env file used by Compose is not automatically a complete application config system. It is input to Compose. If the container needs a variable, pass it explicitly.
For example:
services:
app:
image: registry.example.com/acme/app:${APP_VERSION:?set APP_VERSION}
environment:
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
DATABASE_DSN: ${DATABASE_DSN:?set DATABASE_DSN}
HTTP_PORT: ${HTTP_PORT:-8080}
APP_SECRET_FILE: /run/secrets/app_secret
secrets:
- app_secret
secrets:
app_secret:
file: ./run/secrets/app_secret
The ${NAME:?message} form is useful because Compose fails before starting containers when the value is missing.
To inspect what Compose resolved, use:
docker compose config --environment
That command is a good review tool. It shows which values Compose used for interpolation before you discover a typo at runtime.
Secrets Are Not Just Long Config Values
Secrets deserve a stricter path than normal config.
Putting a password in an environment variable is often better than committing it to a repository, but it is not magic. Environment variables can leak through debugging output, process inspection, crash reports, logs, shell history, and careless support bundles.
Prefer the secret mechanism of the platform you deploy on:
- Docker Compose or Swarm secrets;
- Kubernetes Secrets or an external secrets operator;
- a cloud secret manager;
- a CI/CD secret store;
- encrypted local files for development when the team has a clear workflow.
In containers, a common pattern is to mount the secret as a file and pass the file path through config:
APP_SECRET_FILE=/run/secrets/app_secret
Then the application reads the file at startup. This keeps the secret value out of the normal environment and makes secret delivery explicit.
Also: never print the full config object. Redact secrets before logging.
Config Files Still Have A Place
“Use environment variables” does not mean “ban config files.”
Config files are fine when they describe application structure that is versioned with code:
return [
'routes' => require __DIR__ . '/routes.php',
'services' => require __DIR__ . '/services.php',
];
They are also fine for non-secret defaults:
pagination:
defaultPageSize: 50
maxPageSize: 200
The key rule is simple:
- if a value varies per deploy, inject it at deploy time;
- if a value changes application behavior and should be reviewed with code, keep it in code or versioned config;
- if a value is secret, do not commit it.
Avoid Environment Copies
The old pattern was to keep files such as:
development.yml
staging.yml
production.yml
production-eu.yml
production-eu-hotfix.yml
This scales badly. Files drift. Secrets leak. New deploys require copying old deploys and hoping nobody copied the wrong value.
Prefer granular config values:
APP_ENV=production
DATABASE_DSN=...
REDIS_URL=...
FEATURE_NEW_CHECKOUT=true
PUBLIC_BASE_URL=https://example.com
The environment name can still exist as a label. It should not be the only switch that secretly selects a giant config bundle.
Validate The Contract
Configuration needs tests like any other public interface.
At minimum:
- the app fails when each required value is missing;
- booleans reject unknown strings;
- integers reject values outside the valid range;
- URLs and DSNs are syntactically checked;
- secrets are present but redacted in diagnostic output;
- local example files match the actual required keys.
This can be a unit test around AppConfig::fromEnvironment(). It can also be a startup health check, a schema validation step, or a deployment gate.
The important point is that configuration errors should appear before the application starts serving traffic.
A Practical Layout
For a small service, I would start with this:
config/
services.php
routes.php
src/
AppConfig.php
.env.example
compose.yaml
.env.example documents local development values without containing secrets:
APP_ENV=local
APP_DEBUG=true
DATABASE_DSN=mysql://user:password@db:3306/app
HTTP_PORT=8080
APP_SECRET_FILE=./run/secrets/app_secret
The real local .env stays out of Git. Production values come from the deployment platform.
That is enough for a lot of applications.
The Rule
Configuration is not where you hide uncertainty.
It is the contract that lets the same code run in different places. Keep that contract small, typed, validated, documented, and separated from secrets.
When configuration is treated this way, the application becomes easier to deploy and easier to reason about. When it is not, every deploy becomes a small act of archaeology.