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.

graph LR Client["Client Browser/Postman"] -->|HTTP Request| DockerHost[Docker Host] DockerHost -->|Port Forwarding| DockerContainer[Docker Container] DockerContainer --> FastifyApp[Fastify Application] FastifyApp -- "Response" --> DockerContainer DockerContainer -- "Response" --> DockerHost DockerHost -- "HTTP Response" --> Client

### 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" and moduleResolution: "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" and rootDir: "./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?

  • extends array: Sets up a cascade of recommended rules, ensuring a strong baseline for linting. prettier and plugin:prettier/recommended are 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 like prettier/prettier ensure formatting errors are caught during linting. We’ve also added a warn for no-explicit-any and no-unused-vars to 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 with NodeNext in tsconfig.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 .env files, keeping sensitive information out of version control.
  • Environment-Specific Loading: Prioritizes .env.development, .env.production, etc., over a generic .env file, allowing for flexible environment management.
  • Type Safety: Using parseInt and default values ensures that port is always a number and provides fallbacks.
  • 0.0.0.0 Host: 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 pino by 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 /health endpoint is standard practice for monitoring and load balancers to check if the application is running and responsive.
  • Centralized Error Handling: The setErrorHandler ensures 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 configured port and host.
  • Graceful Shutdown: Implements handlers for SIGTERM (sent by process managers like Docker or Kubernetes) and SIGINT (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: Uses ts-node-dev for a hot-reloading development experience, automatically restarting the server when TypeScript files change. --transpile-only speeds 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.
    • builder stage: Installs dev dependencies and compiles TypeScript.
    • runner stage: Only copies node_modules and the compiled dist folder. This results in a significantly smaller final image, reducing attack surface and deployment times.
  • Slim Images: node:20-slim is used to minimize image size compared to full Node.js images.
  • WORKDIR /app: Sets the working directory inside the container.
  • Dependency Caching: Copying package.json and package-lock.json and running npm install before copying the rest of the code leverages Docker’s layer caching. If package.json doesn’t change, subsequent builds can reuse the npm install layer, 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 our Dockerfile.
  • 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 local src directory into the container. When you make changes to your TypeScript files, ts-node-dev inside 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’s node_modules from being mounted over the container’s node_modules (which were installed by npm install inside 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 the CMD in the Dockerfile for local development, ensuring ts-node-dev is 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:

  1. Build the Docker image (this might take a few minutes the first time).
  2. Start the nodejs-app container.
  3. Execute npm run dev inside the container, starting ts-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.ts and dotenv allows 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: Runs ts-node-dev for 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

  1. ts-node-dev not restarting or type errors:

    • Issue: ts-node-dev might not restart, or you see type errors.
    • Solution: Ensure tsconfig.json is correctly configured (rootDir, outDir, moduleResolution). Check package.json for ts-node-dev flags (--respawn, --transpile-only). Sometimes, a fresh npm install or clearing node_modules and dist can help.
    • Prevention: Always verify tsconfig.json paths match your project structure.
  2. Docker container not accessible or failing to start:

    • Issue: localhost:3000 doesn’t work, or Docker Compose logs show errors.
    • Solution:
      • Verify ports mapping in docker-compose.yml (3000:3000).
      • Ensure your Fastify app is listening on 0.0.0.0 (check src/config/index.ts and src/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.
    • Prevention: Always use 0.0.0.0 for host in containerized apps. Check logs thoroughly.
  3. 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-prettier and eslint-plugin-prettier are correctly installed and ordered last in the extends array of .eslintrc.js. Run npm run format then npm run lint:fix to resolve.
    • Prevention: Follow the recommended ESLint/Prettier setup order strictly.

Testing & Verification

To ensure everything is correctly set up for the current chapter:

  1. Run npm run build: This should compile your TypeScript code without errors and create a dist folder.
  2. Run npm run lint and npm run check-format: Both should report no errors.
  3. 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/health in your browser and confirm the JSON response.
    • Modify src/app.ts (e.g., change the health message) and observe ts-node-dev restarting the server.
    • Press Ctrl+C and confirm graceful shutdown.
  4. Run npm run docker:up:
    • Verify the Docker container builds and starts successfully.
    • Access http://localhost:3000/health and confirm the JSON response from the containerized app.
    • Modify src/app.ts and confirm hot-reloading works within the Docker container.
    • Run npm run docker:down and 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!