Fastify Best Practices #1: Environment Variables
Foreword: Why Are Environment Variables So Important?
In modern application development, separating configuration from code is a golden rule. Our applications need to be deployed in different environments, such as local development, Continuous Integration (CI), testing, and production. Each environment has its unique configuration, like database connection strings, API keys, port numbers, etc.
Environment variables are the perfect tool to achieve this separation. Managing them correctly can significantly enhance our application’s flexibility, security, and maintainability.
This blog post will start with common practices in Fastify development, analyze their pain points, and ultimately guide you to master a purer, more powerful, and cloud-native-ready approach to managing environment variables.
1. The Common Approach: Using dotenv in Your Code
For most Node.js developers, dotenv is a very familiar library. It loads variables from a .env file into process.env.
A typical project structure might include the following files:
.env: For the local development environment..env.test: For the testing environment..env.production: For the production environment.
To make a Fastify app load these variables, we usually add the following code at the top of our entry file (e.g., index.ts):
// index.ts
import dotenv from 'dotenv';
import path from 'path';
// Load the appropriate .env file based on NODE_ENV
const envFile = process.env.NODE_ENV ? `.env.${process.env.NODE_ENV}` : '.env';
dotenv.config({ path: path.resolve(__dirname, envFile) });
// ... Fastify startup code follows
While this method works, it has some significant pain points:
- Configuration Coupled with Code: The logic for loading environment variables invades the business logic. The code’s responsibility should be to implement business features, not to worry about which environment it’s running in or how to load its configuration.
- Impure Build Artifacts: During the build process, all
.envfiles (.env.test,.env.production, etc.) might be included in the final build artifact. This not only increases the bundle size but could also inadvertently leak sensitive configurations from other environments. - Inconsistent Configuration Sources: When using Docker or CI/CD tools, some configuration might come from the server’s environment variables, while another part comes from the project’s
.envfiles. This leads to chaotic management and makes troubleshooting more difficult.
2. The Best Practice: Let Configuration Be Injected at Runtime
I believe that code should be as pure as possible; the final build should not contain anything unrelated to business logic. Therefore, configuration should be stored in the environment. This means the application itself should be environment-agnostic. It only reads configuration from its runtime environment and doesn’t need to know how that configuration was injected.
To achieve this, we need to separate the process of loading environment variables from our application code and move it to a step before the application starts.
Method 1: Use dotenv-cli (The Universal Solution)
dotenv-cli is a simple and effective command-line tool that loads a specified .env file into the environment before executing any command.
Step 1: Install dotenv-cli as a development dependency.
npm i -D dotenv-cli
# or
yarn add -D dotenv-cli
Step 2: Modify the scripts in your package.json.
We no longer need to import 'dotenv' in our code. Instead, we prepend the dotenv command to our start scripts.
// package.json
{
"scripts": {
"start:dev": "dotenv -e .env -- ts-node index.ts",
"start:prod": "dotenv -e .env.production -- node dist/index.js",
"test": "dotenv -e .env.test -- jest"
}
}
Method 2: Use Node.js’s Native Capabilities (The Modern Solution)
The good news is that starting from Node.js v20.6.0, Node.js natively supports the --env-file flag. This means we no longer need any external dependencies to achieve the same result!
Here’s how to use it:
// package.json
{
"scripts": {
"start:dev": "node --env-file=.env --loader=ts-node/esm index.ts",
"start:prod": "node --env-file=.env.production dist/index.js"
}
}
3. Going Further: Validate Your Environment Variables
Just loading environment variables isn’t enough. A robust application also needs to ensure these variables exist and are valid. For example, PORT must be a number, and API_URL must be a valid URL.
Fastify’s official plugin, @fastify/env, is the perfect tool for this job. It validates environment variables against a schema you define when the application starts. If validation fails, the app will immediately exit with an error, preventing potential issues caused by faulty configuration at runtime.
Step 1: Install the plugin.
npm i @fastify/env
# or
yarn add @fastify/env
Step 2: Register and configure it in Fastify.
import fastifyEnv from '@fastify/env';
const schema = {
type: 'object',
required: ['PORT', 'DATABASE_URL'],
properties: {
PORT: {
type: 'string',
default: '3000',
},
DATABASE_URL: {
type: 'string',
},
LOG_LEVEL: {
type: 'string',
default: 'info',
},
},
};
const options = {
confKey: 'config', // The key to register the config under in the fastify instance. Default is 'config'
schema: schema,
};
await fastify.register(fastifyEnv, options);
// In your application, you can access the validated and type-coerced variables via fastify.config
// e.g., fastify.config.PORT
With this approach, we not only load our environment variables but also build a reliable “firewall” for them.
4. Summary
Let’s review this evolutionary path:
- Initial Stage: Using
dotenvwithin the code to load configuration, leading to coupling between code and config. - Best Practice: Using
dotenv-clior the native Node.js--env-fileflag to inject configuration before the app starts, achieving a clean separation of code and config. - Advanced Enhancement: Using
@fastify/envto perform startup-time validation of environment variables, increasing the application’s robustness.
This pattern of separating configuration from code and injecting it at runtime aligns perfectly with the philosophy of containerization technologies like Docker and Kubernetes. In Docker, we can start a container with docker run --env-file .env.production ...; in Kubernetes, we can use ConfigMap and Secret to manage configuration. Our application can be seamlessly deployed on this modern infrastructure without any changes.
I hope this sharing helps you build more professional and reliable Fastify applications.