Welcome to the first chapter of our comprehensive Node.js backend project guide! In this foundational chapter, we will lay the groundwork for a robust, scalable, and maintainable application. We’ll start by initializing a new Node.js project, setting up TypeScript for improved code quality, and integrating essential development tools like ESLint and Prettier for consistent code style. Our primary web framework will be Fastify, chosen for its speed, low overhead, and powerful plugin architecture, aligning with modern Node.js best practices.
The importance of a well-structured and configured development environment cannot be overstated. It sets the stage for efficient development, easier collaboration, and fewer bugs down the line. By establishing these best practices from the outset, we ensure our project is production-ready from day one. We’ll also introduce Docker for local development, providing a consistent and isolated environment that mirrors our future production setup.
By the end of this chapter, you will have a fully initialized Node.js project with TypeScript, linting, formatting, and a basic “hello world” Fastify server running consistently within a Docker container. This setup will serve as the bedrock for all subsequent chapters, where we will incrementally build out more complex features.
Prerequisites
Before we begin, please ensure you have the following installed on your system:
- Node.js (LTS version, v20.x or higher): Download from nodejs.org.
- npm (comes with Node.js): Verify with
npm -v. - Docker Desktop: Download from docker.com.
Planning & Design
Our initial project structure will emphasize modularity and separation of concerns. This structure is designed to scale gracefully as our application grows, making it easier to manage code, add features, and onboard new developers.
Project Structure
.
├── src/
│ ├── config/ # Environment-specific configurations
│ ├── plugins/ # Fastify plugins for shared functionality
│ ├── routes/ # API route definitions
│ ├── services/ # Business logic and data manipulation
│ ├── utils/ # Utility functions
│ ├── app.ts # Fastify application instance
│ └── server.ts # Application entry point
├── tests/ # Unit and integration tests
├── .env.example # Example environment variables
├── .eslintrc.js # ESLint configuration
├── .gitignore # Git ignore file
├── .prettierrc.js # Prettier configuration
├── Dockerfile # Docker build instructions
├── docker-compose.yml # Docker Compose configuration for local dev
├── package.json # Project metadata and scripts
├── tsconfig.json # TypeScript configuration
└── README.md # Project documentation
Initial System Architecture
For this chapter, the architecture is quite straightforward: a client (your browser or a tool like Postman) making requests to our Fastify server, which will be running inside a Docker container.
### Step-by-Step Implementation
Let's begin building our project piece by piece.
#### 3.1. Initialize Node.js Project and Git
First, create a new directory for our project and initialize it with npm and Git.
##### a) Setup/Configuration
Open your terminal and execute the following commands:
```bash
mkdir nodejs-production-app
cd nodejs-production-app
npm init -y
git init
The npm init -y command creates a package.json file with default values. git init initializes a new Git repository.
Next, create a .gitignore file to prevent unnecessary files from being committed to our repository.
Create file: .gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Dependency directories
node_modules/
# IDE files
.idea/
.vscode/
*.swp
.DS_Store
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build artifacts
dist/
build/
coverage/
# Docker
docker-compose.override.yml
*.env
Why this decision?
Ignoring node_modules, dist/, .env, and IDE-specific files ensures that only source code and essential configuration are tracked by Git. This keeps the repository clean and prevents environment-specific files from causing conflicts.
3.2. Configure TypeScript
TypeScript adds static typing to JavaScript, which significantly improves code quality, readability, and maintainability, especially in larger projects.
a) Setup/Configuration
Install TypeScript and its Node.js type definitions as development dependencies:
npm install --save-dev typescript @types/node
Now, create a tsconfig.json file in the root of your project. This file configures the TypeScript compiler.
Create file: tsconfig.json
{
"compilerOptions": {
"target": "ES2022", /* Specify ECMAScript target version: "ES3" (default), "ES5", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "ES2020", "ES2021", "ES2022", "ESNext". */
"module": "NodeNext", /* Specify module code generation: "None", "CommonJS", "AMD", "System", "UMD", "ES6", "ES2015", "ES2020", "ES2022", "ESNext", "NodeNext". */
"moduleResolution": "NodeNext", /* Specify how modules are resolved. */
"lib": ["ES2022"], /* Specify a list of library files to be included in the compilation. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of source files. */
"strict": true, /* Enable all strict type-checking options. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
"resolveJsonModule": true, /* Allow importing .json files. */
"sourceMap": true, /* Generate .map files for debugging. */
"incremental": true, /* Enable incremental compilation. */
"noEmitOnError": true /* Do not emit outputs if any type checking errors are reported. */
},
"include": ["src/**/*.ts"], /* Specify an array of filenames or patterns to include in the program. */
"exclude": ["node_modules", "dist"] /* Specify an array of filenames or patterns that should be excluded from the program. */
}
Why these decisions?
target: "ES2022": Targets a modern ECMAScript version, allowing us to use recent JavaScript features directly without much transpilation overhead.module: "NodeNext"andmoduleResolution: "NodeNext": These are crucial for modern Node.js projects, aligning with Node’s native ES Modules (ESM) support. This ensures correct module resolution, especially when dealing with both CommonJS and ESM packages.outDir: "./dist"androotDir: "./src": Separates compiled JavaScript from source TypeScript, maintaining a clean project structure.strict: true: Enables a wide range of type-checking options, promoting robust and error-free code. This is a critical best practice for production-ready applications.esModuleInterop: true: Essential for working with CommonJS modules in a TypeScript ESM context.
3.3. Setup Linting and Formatting (ESLint & Prettier)
Consistent code style and early error detection are vital for collaboration and long-term maintainability. ESLint helps find and fix problematic patterns, while Prettier ensures consistent formatting.
a) Setup/Configuration
Install the necessary packages:
npm install --save-dev eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-prettier
eslint: The core ESLint library.prettier: The code formatter.@typescript-eslint/parser: Allows ESLint to parse TypeScript code.@typescript-eslint/eslint-plugin: Provides TypeScript-specific linting rules.eslint-config-prettier: Turns off ESLint rules that might conflict with Prettier.eslint-plugin-prettier: Runs Prettier as an ESLint rule.
Create .eslintrc.js for ESLint configuration:
Create file: .eslintrc.js
module.exports = {
parser: "@typescript-eslint/parser", // Specifies the ESLint parser
extends: [
"eslint:recommended", // Use recommended ESLint rules
"plugin:@typescript-eslint/recommended", // Use recommended rules from @typescript-eslint/eslint-plugin
"prettier", // Uses eslint-config-prettier to disable ESLint rules that would conflict with Prettier
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
],
parserOptions: {
ecmaVersion: 2022, // Allows for the parsing of modern ECMAScript features
sourceType: "module", // Allows for the use of imports
project: "./tsconfig.json" // Specify project for type-aware linting
},
env: {
node: true, // Enable Node.js global variables and Node.js scoping.
es2022: true // Add all ECMAScript 2022 globals and automatically set the ecmaVersion parser option to ES2022.
},
rules: {
// Place to add your custom ESLint rules
// e.g., "@typescript-eslint/explicit-function-return-type": "off",
"prettier/prettier": ["error", { "endOfLine": "auto" }], // Enforce Prettier rules as ESLint errors
"@typescript-eslint/no-explicit-any": "warn", // Allow 'any' but warn
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] // Warn about unused variables, ignore args starting with _
}
};
Why these decisions?
extendsarray: Sets up a cascade of recommended rules, ensuring a strong baseline for linting.prettierandplugin:prettier/recommendedare crucial to integrate Prettier smoothly, making it the source of truth for formatting.parserOptions.project: Enables type-aware linting, which allows ESLint to catch more sophisticated TypeScript-specific issues.rules: Custom rules likeprettier/prettierensure formatting errors are caught during linting. We’ve also added awarnforno-explicit-anyandno-unused-varsto allow flexibility during development while still flagging potential issues.
Create .prettierrc.js for Prettier configuration:
Create file: .prettierrc.js
module.exports = {
semi: true, // Add semicolons at the end of statements
trailingComma: "all", // Print trailing commas wherever possible
singleQuote: false, // Use double quotes instead of single quotes
printWidth: 120, // Specify the line length that the printer will wrap on
tabWidth: 2, // Specify the number of spaces per indentation-level
useTabs: false, // Indent with spaces instead of tabs
endOfLine: "auto" // Maintain existing line endings (lf or crlf)
};
Why these decisions?
These are common and widely accepted Prettier configurations that ensure consistent and readable code across the project. endOfLine: "auto" is particularly useful for cross-platform development.
Finally, add scripts to package.json for linting and formatting:
Modify file: package.json
{
"name": "nodejs-production-app",
"version": "1.0.0",
"description": "A progressive Node.js backend project with production-ready best practices.",
"main": "dist/server.js",
"type": "module",
"scripts": {
"build": "tsc",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"check-format": "prettier --check \"src/**/*.ts\"",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "AI Expert",
"license": "ISC",
"devDependencies": {
"@types/node": "^20.11.19",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"typescript": "^5.3.3"
}
}
Why these decisions?
"type": "module": Explicitly sets the project to use ES Modules, aligning withNodeNextintsconfig.json. This is the modern standard for Node.js.build: Compiles TypeScript to JavaScript.lint,lint:fix,format,check-format: Provide convenient commands to manage code quality and style.
c) Testing This Component
To test our linting and formatting setup, let’s create a temporary file with some style violations.
Create file: src/temp.ts (this file will be deleted later)
function calculateSum( a: number,b:number) :number{
const sum = a+b ;
return sum}
Now, run the check-format script and then lint to see the errors:
npm run check-format
npm run lint
You should see output indicating formatting and linting errors. Now, let’s fix them:
npm run format
npm run lint:fix
Running npm run check-format and npm run lint again should now show no errors. Delete src/temp.ts after verification.
3.4. Implement a Basic Fastify Server
Now that our development environment is set up, let’s create a simple Fastify server.
a) Setup/Configuration
Install Fastify:
npm install fastify
npm install --save-dev @types/fastify ts-node-dev
fastify: The web framework itself.@types/fastify: TypeScript type definitions for Fastify.ts-node-dev: A tool for running TypeScript files with automatic restarts on file changes, perfect for development.
b) Core Implementation
Let’s create our application entry point and the Fastify application instance.
Create directory: src/config
Create file: src/config/index.ts
import dotenv from "dotenv";
import path from "path";
// Load environment variables from .env file based on NODE_ENV
const envPath = path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`);
dotenv.config({ path: envPath });
dotenv.config({ path: path.resolve(process.cwd(), ".env") }); // Load generic .env last to be overridden by specific env
const config = {
env: process.env.NODE_ENV || "development",
port: parseInt(process.env.PORT || "3000", 10),
host: process.env.HOST || "0.0.0.0", // Listen on all network interfaces by default
logLevel: process.env.LOG_LEVEL || "info",
// Add other configurations here as needed, e.g., database connection strings, JWT secrets
};
export default config;
Why this decision?
- Centralized Configuration: All environment-specific variables are loaded and managed in one place, making it easy to see and modify settings.
dotenv: Securely loads environment variables from.envfiles, keeping sensitive information out of version control.- Environment-Specific Loading: Prioritizes
.env.development,.env.production, etc., over a generic.envfile, allowing for flexible environment management. - Type Safety: Using
parseIntand default values ensures thatportis always a number and provides fallbacks. 0.0.0.0Host: Crucial when running inside a Docker container, as it allows the server to listen on all available network interfaces, making it accessible from outside the container.
Create file: src/app.ts
import Fastify from "fastify";
import config from "./config";
import { pinoLogger } from "./utils/logger"; // We will create this next
// Create a Fastify instance
const app = Fastify({
logger: pinoLogger(config.logLevel), // Use our custom logger
});
// Define a simple health check route
app.get("/health", async (request, reply) => {
app.log.info("Health check requested");
return { status: "ok", uptime: process.uptime(), timestamp: new Date() };
});
// Centralized error handling (will be expanded in future chapters)
app.setErrorHandler(async (error, request, reply) => {
app.log.error({ error, request: request.raw }, "Unhandled error occurred");
// For now, send a generic error message in production
if (config.env === "production") {
reply.status(500).send({ error: "Internal Server Error" });
} else {
reply.status(500).send({ error: error.message || "Internal Server Error", stack: error.stack });
}
});
export default app;
Why these decisions?
- Fastify Instance: Initializes our web server.
- Logger Integration: Fastify uses
pinoby default. We’re setting up a custom logger wrapper (pinoLogger) to ensure consistent logging across our application and to allow easy configuration of log levels. - Health Check Endpoint: A
/healthendpoint is standard practice for monitoring and load balancers to check if the application is running and responsive. - Centralized Error Handling: The
setErrorHandlerensures that all uncaught errors are handled gracefully. In production, we provide a generic message to avoid leaking sensitive information. This will be expanded upon in a dedicated chapter.
Let’s create the logger utility.
Create directory: src/utils
Create file: src/utils/logger.ts
import pino, { LoggerOptions } from "pino";
// Configure Pino logger
export const pinoLogger = (level: string = "info") => {
const options: LoggerOptions = {
level: level,
transport: {
target: "pino-pretty", // Use pino-pretty for development for better readability
options: {
colorize: true,
translateTime: "SYS:HH:MM:ss Z",
ignore: "pid,hostname",
},
},
// Customize serializers for specific objects (e.g., requests, replies)
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
hostname: req.hostname,
remoteAddress: req.remoteAddress,
}),
res: pino.stdSerializers.res, // Default response serializer
err: pino.stdSerializers.err, // Default error serializer
},
};
// In production, do not use pino-pretty as it adds overhead.
// Instead, log directly to stdout as JSON.
if (process.env.NODE_ENV === "production") {
delete options.transport; // Remove transport for production
options.formatters = {
level: (label) => ({ level: label }), // Ensure level is a string
};
}
return pino(options);
};
Why these decisions?
- Pino: A highly performant Node.js logger.
pino-pretty: Provides human-readable log output in development, making debugging easier. It’s conditionally removed in production for performance.- Serializers: Customize how objects like requests, responses, and errors are logged, ensuring relevant information is captured without excessive verbosity.
- Production vs. Development: Differentiates logging strategy for different environments, a crucial best practice.
Now, create the main server entry point.
Create file: src/server.ts
import app from "./app";
import config from "./config";
const start = async () => {
try {
await app.listen({ port: config.port, host: config.host });
app.log.info(`Server listening on ${config.host}:${config.port} in ${config.env} mode`);
} catch (err) {
app.log.error("Server failed to start:", err);
process.exit(1);
}
};
// Graceful shutdown
const gracefulShutdown = async () => {
app.log.info("Shutting down server...");
await app.close();
app.log.info("Server gracefully stopped.");
process.exit(0);
};
process.on("SIGTERM", gracefulShutdown);
process.on("SIGINT", gracefulShutdown);
start();
Why these decisions?
start()function: Encapsulates the server startup logic, including error handling.app.listen(): Starts the Fastify server using the configuredportandhost.- Graceful Shutdown: Implements handlers for
SIGTERM(sent by process managers like Docker or Kubernetes) andSIGINT(Ctrl+C). This ensures that the server properly closes connections and cleans up resources before exiting, preventing data loss or orphaned processes. This is a critical production-readiness feature. - Logging: Provides clear messages for server startup and shutdown, aiding in monitoring.
Finally, update package.json with scripts to run the server:
Modify file: package.json
{
"name": "nodejs-production-app",
"version": "1.0.0",
"description": "A progressive Node.js backend project with production-ready best practices.",
"main": "dist/server.js",
"type": "module",
"scripts": {
"build": "tsc",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"check-format": "prettier --check \"src/**/*.ts\"",
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "AI Expert",
"license": "ISC",
"devDependencies": {
"@types/fastify": "^2.11.0",
"@types/node": "^20.11.19",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
},
"dependencies": {
"dotenv": "^16.4.5",
"fastify": "^4.26.1",
"pino": "^8.19.0",
"pino-pretty": "^10.3.1"
}
}
Why these decisions?
start: The command to run the compiled application in production.dev: Usests-node-devfor a hot-reloading development experience, automatically restarting the server when TypeScript files change.--transpile-onlyspeeds up development by skipping type checks during restarts.
c) Testing This Component
Before Docker, let’s test our server locally.
Create file: .env.development
NODE_ENV=development
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
Now, start the development server:
npm run dev
You should see output similar to:
[INFO] 12:00:00 PM - Server listening on 0.0.0.0:3000 in development mode
Open your browser or Postman and navigate to http://localhost:3000/health. You should receive a JSON response:
{
"status": "ok",
"uptime": /* some number */,
"timestamp": "2026-01-08T..."
}
Press Ctrl+C in your terminal to stop the server. You should see the graceful shutdown message.
3.5. Dockerize the Application for Local Development
Containerization with Docker provides consistency across different development environments and simplifies deployment.
a) Setup/Configuration
Create file: Dockerfile
# Stage 1: Builder
# Use a slim Node.js image for building
FROM node:20-slim AS builder
WORKDIR /app
# Copy package.json and package-lock.json to install dependencies
COPY package.json package-lock.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application source code
COPY . .
# Build the TypeScript application
RUN npm run build
# Stage 2: Production Runtime
# Use a minimal Node.js image for the final production image
FROM node:20-slim AS runner
WORKDIR /app
# Copy production dependencies from builder stage
COPY --from=builder /app/node_modules ./node_modules
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Expose the port the app runs on
EXPOSE 3000
# Set NODE_ENV for production
ENV NODE_ENV=production
# Command to run the application
CMD ["npm", "start"]
Why this decision?
- Multi-stage Build: This is a crucial best practice for Docker.
builderstage: Installs dev dependencies and compiles TypeScript.runnerstage: Only copiesnode_modulesand the compileddistfolder. This results in a significantly smaller final image, reducing attack surface and deployment times.
- Slim Images:
node:20-slimis used to minimize image size compared to full Node.js images. WORKDIR /app: Sets the working directory inside the container.- Dependency Caching: Copying
package.jsonandpackage-lock.jsonand runningnpm installbefore copying the rest of the code leverages Docker’s layer caching. Ifpackage.jsondoesn’t change, subsequent builds can reuse thenpm installlayer, speeding up builds. EXPOSE 3000: Informs Docker that the container listens on port 3000.ENV NODE_ENV=production: Sets the environment to production for the final image, ensuring production-specific configurations are used.CMD ["npm", "start"]: The default command to run when the container starts.
Create file: .dockerignore
node_modules
dist
.env
.git
.gitignore
Dockerfile
docker-compose.yml
npm-debug.log*
yarn-debug.log*
.vscode
Why this decision?
Similar to .gitignore, .dockerignore prevents unnecessary files from being copied into the Docker build context, speeding up builds and reducing image size. We explicitly ignore node_modules and dist because they are either installed/built within the Dockerfile or copied selectively from a builder stage.
Create file: docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: nodejs-app
restart: unless-stopped
env_file:
- .env.development # Load environment variables for local development
ports:
- "3000:3000" # Map host port 3000 to container port 3000
volumes:
- ./src:/app/src # Mount source code for hot-reloading in dev
- /app/node_modules # Anonymous volume to prevent host node_modules from overwriting container's
command: npm run dev # Use the dev script for local development with ts-node-dev
Why this decision?
version: '3.8': Specifies the Docker Compose file format version.build: Tells Docker Compose to build the image from ourDockerfile.container_name: Provides a readable name for the container.restart: unless-stopped: Ensures the container restarts automatically unless manually stopped, good for reliability.env_file: .env.development: Loads environment variables specific to our local development setup directly into the container.ports: "3000:3000": Maps port 3000 on your host machine to port 3000 inside the container, allowing you to access the app.volumes:./src:/app/src: This is crucial for development. It mounts your localsrcdirectory into the container. When you make changes to your TypeScript files,ts-node-devinside the container will detect them and restart the server, providing a hot-reloading experience./app/node_modules: This is an anonymous volume. It prevents the host’snode_modulesfrom being mounted over the container’snode_modules(which were installed bynpm installinside the container). This ensures that the dependencies inside the container are always the correct ones for the container’s environment.
command: npm run dev: Overrides theCMDin theDockerfilefor local development, ensuringts-node-devis used for hot-reloading.
b) Core Implementation (Docker Scripts)
Add Docker-related scripts to package.json for convenience:
Modify file: package.json
{
"name": "nodejs-production-app",
"version": "1.0.0",
"description": "A progressive Node.js backend project with production-ready best practices.",
"main": "dist/server.js",
"type": "module",
"scripts": {
"build": "tsc",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"check-format": "prettier --check \"src/**/*.ts\"",
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"docker:build": "docker compose build",
"docker:up": "docker compose up --build",
"docker:down": "docker compose down"
},
"keywords": [],
"author": "AI Expert",
"license": "ISC",
"devDependencies": {
"@types/fastify": "^2.11.0",
"@types/node": "^20.11.19",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
},
"dependencies": {
"dotenv": "^16.4.5",
"fastify": "^4.26.1",
"pino": "^8.19.0",
"pino-pretty": "^10.3.1"
}
}
Why these decisions?
docker:build: Builds the Docker image.docker:up: Builds (if necessary) and starts the container, linking to the host port.docker:down: Stops and removes the container and its associated network.
c) Testing This Component
Now, let’s test our application running inside Docker.
First, ensure Docker Desktop is running. Then, from your project root:
npm run docker:up
This command will:
- Build the Docker image (this might take a few minutes the first time).
- Start the
nodejs-appcontainer. - Execute
npm run devinside the container, startingts-node-dev.
You should see logs from ts-node-dev and Fastify in your terminal, indicating the server is running.
nodejs-app | [INFO] 12:00:00 PM - Server listening on 0.0.0.0:3000 in development mode
Open your browser or Postman and navigate to http://localhost:3000/health. You should again receive the health check JSON response.
To verify hot-reloading, try changing the status message in src/app.ts from "ok" to "healthy" and save the file. You should see ts-node-dev automatically restart the server in your terminal logs, and refreshing http://localhost:3000/health will show the updated response.
When you’re done, stop the Docker container:
npm run docker:down
3.6. Environment Variables
We’ve already set up dotenv and src/config/index.ts. Let’s ensure our .env.development is in place for local development. For production, we would use a different .env.production or rely on the deployment environment to inject these variables.
Create file: .env.production (for future use)
NODE_ENV=production
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
# Add production-specific variables here
# DATABASE_URL=...
# JWT_SECRET=...
Why this decision?
Separating environment files (.env.development, .env.production) is a best practice. It allows you to define different configurations for different environments without modifying code. Remember that .env files should never be committed to version control.
Production Considerations
Even in this initial setup, we’ve incorporated several production-ready principles:
- Multi-stage Docker Builds: Reduces the final image size and attack surface, critical for production deployments.
- Graceful Shutdown: Ensures that the application can be stopped without losing in-flight requests or corrupting data.
- Centralized Configuration: Using
src/config/index.tsanddotenvallows for easy management of environment variables, which will be crucial for managing secrets and different settings in production (e.g., database URLs, API keys). - Robust Logging: Pino is a high-performance logger suitable for production, and our setup differentiates between development (human-readable) and production (JSON for machine parsing) output.
- Basic Error Handling: A centralized error handler catches uncaught errors and prevents sensitive information from being leaked in production.
Code Review Checkpoint
At this point, your project structure should look like this:
nodejs-production-app/
├── src/
│ ├── config/
│ │ └── index.ts
│ ├── utils/
│ │ └── logger.ts
│ ├── app.ts
│ └── server.ts
├── .env.development
├── .env.production
├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── Dockerfile
├── docker-compose.yml
├── package.json
├── tsconfig.json
└── README.md
You should have the following scripts in your package.json:
build: Compiles TypeScript.lint,lint:fix,format,check-format: For code quality.start: Runs the compiled JS in production.dev: Runsts-node-devfor development with hot-reloading.docker:build,docker:up,docker:down: For Docker management.
All code should be type-checked by TypeScript, linted by ESLint, and formatted by Prettier.
Common Issues & Solutions
ts-node-devnot restarting or type errors:- Issue:
ts-node-devmight not restart, or you see type errors. - Solution: Ensure
tsconfig.jsonis correctly configured (rootDir,outDir,moduleResolution). Checkpackage.jsonforts-node-devflags (--respawn,--transpile-only). Sometimes, a freshnpm installor clearingnode_modulesanddistcan help. - Prevention: Always verify
tsconfig.jsonpaths match your project structure.
- Issue:
Docker container not accessible or failing to start:
- Issue:
localhost:3000doesn’t work, or Docker Compose logs show errors. - Solution:
- Verify
portsmapping indocker-compose.yml(3000:3000). - Ensure your Fastify app is listening on
0.0.0.0(checksrc/config/index.tsandsrc/server.ts). - Check Docker logs (
docker compose logs app) for application-level errors. - Make sure Docker Desktop is running.
- Ensure no other process on your host is using port 3000.
- Verify
- Prevention: Always use
0.0.0.0for host in containerized apps. Check logs thoroughly.
- Issue:
ESLint/Prettier conflicts or errors:
- Issue: ESLint reports formatting errors even after running Prettier, or Prettier changes code that ESLint then flags.
- Solution: Ensure
eslint-config-prettierandeslint-plugin-prettierare correctly installed and ordered last in theextendsarray of.eslintrc.js. Runnpm run formatthennpm run lint:fixto resolve. - Prevention: Follow the recommended ESLint/Prettier setup order strictly.
Testing & Verification
To ensure everything is correctly set up for the current chapter:
- Run
npm run build: This should compile your TypeScript code without errors and create adistfolder. - Run
npm run lintandnpm run check-format: Both should report no errors. - Run
npm run dev:- Verify the server starts successfully and logs
Server listening on 0.0.0.0:3000 in development mode. - Access
http://localhost:3000/healthin your browser and confirm the JSON response. - Modify
src/app.ts(e.g., change the health message) and observets-node-devrestarting the server. - Press
Ctrl+Cand confirm graceful shutdown.
- Verify the server starts successfully and logs
- Run
npm run docker:up:- Verify the Docker container builds and starts successfully.
- Access
http://localhost:3000/healthand confirm the JSON response from the containerized app. - Modify
src/app.tsand confirm hot-reloading works within the Docker container. - Run
npm run docker:downand confirm the container stops gracefully.
If all these steps pass, your development environment is fully set up and ready for building.
Summary & Next Steps
Congratulations! In this chapter, you’ve successfully initialized a robust Node.js project. We established a modern development environment complete with TypeScript, ESLint, Prettier, and a production-ready Fastify server structure. Crucially, we containerized our application using Docker and Docker Compose, ensuring a consistent and isolated development experience that mirrors our future production deployment.
This foundational work is critical. It provides the stability, maintainability, and efficiency needed for building complex applications. We’ve also incorporated best practices like graceful shutdown, centralized configuration, and intelligent logging from the very beginning.
In Chapter 2: Designing & Implementing Core API Routes, we will expand on this foundation by:
- Implementing a more structured approach to defining Fastify routes.
- Introducing request validation.
- Setting up basic data models and a simple in-memory “database” to simulate data storage.
- Creating CRUD (Create, Read, Update, Delete) operations for a resource.
Get ready to dive deeper into Fastify’s capabilities and start building out our API!