Express foundations
Configure without global mystery
Read, validate, and pass configuration deliberately instead of reaching into process.env everywhere.
Configuration is input. It comes from outside the program, it can be missing, and it can be wrong. So treat it like any other input: read it once, check it, and pass the result around.
The alternative is process.env.SESSION_SECRET sprinkled through twelve files. That works until the day the variable is missing in production and the app starts anyway. The first user to log in gets a crash instead of you getting an error at startup.
Read everything in one place
Create src/config.js. It reads the environment once and returns a plain object:
export function loadConfig(env = process.env) {
const port = Number(env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`PORT must be a valid port number, got "${env.PORT}"`)
}
if (!env.SESSION_SECRET || env.SESSION_SECRET.length < 32) {
throw new Error('SESSION_SECRET is required and must be at least 32 characters')
}
return {
port,
sessionSecret: env.SESSION_SECRET,
production: env.NODE_ENV === 'production',
trustProxy: env.NODE_ENV === 'production' ? 1 : false,
}
}
Three decisions are visible here. The port has a safe local default. The session secret has no default, because a default secret is a known secret. And trustProxy is explicit per environment instead of being guessed later.
env is a parameter so a test can pass { PORT: 'abc' } without touching the real environment.
Pass it in, don’t reach out
createApp() takes the config as an argument:
export function createApp({ config }) {
const app = express()
app.set('trust proxy', config.trustProxy)
// ...
return app
}
And src/server.js wires it together:
import { loadConfig } from './config.js'
import { createApp } from './app.js'
const config = loadConfig()
const app = createApp({ config })
app.listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`)
})
No file other than config.js mentions process.env. I check this with a quick grep -r process.env src/ before every release.
Local values without a library
Put local values in a .env file and add it to .gitignore:
PORT=3000
SESSION_SECRET=change-me-to-a-long-random-string-please
Node can load it natively, no dotenv package needed:
node --env-file=.env src/server.js
Update the dev script to node --watch --env-file=.env src/server.js.
Watch it fail on purpose
Run the three cases and look at each result. With a complete .env you get the usual Listening on http://localhost:3000. Remove SESSION_SECRET and the process exits immediately with:
Error: SESSION_SECRET is required and must be at least 32 characters
Set PORT=http and you get the port error, again before anything listens.
This is the behavior we want. A misconfigured server that refuses to start is a deploy that fails loudly in a minute. A misconfigured server that starts is a bug report from a user tomorrow.
Lesson completed