DEV Community

Cover image for How to Create a Production-Ready Dockerfile for Node.js Applications: A Step-by-Step Guide
Taiwo Shobo
Taiwo Shobo

Posted on Edited on

How to Create a Production-Ready Dockerfile for Node.js Applications: A Step-by-Step Guide

Introduction

What is a Dockerfile?

A Dockerfile is a text file containing instructions for building a Docker image. It defines everything an application needs to run, including the base image, dependencies, application files, and startup commands.

Think of a Dockerfile as a blueprint for your application's environment. It packages your code, dependencies, and runtime into a portable container, ensuring consistency across different environments and eliminating the common "it works on my machine but not on yours" problem. A well-written Dockerfile also helps keep your images lightweight, efficient, and secure.

In this tutorial, we'll build a simple Recipe App with Node.js, Express, and MySQL, and build the Docker image for the app step by step

Prerequisites

  • Basic knowledge of JavaScript and ES6 syntax
  • Installation of Node.js on your system
  • Basic knowledge of Structured Query Language(SQL)
  • Installation of MySQL Workbench on your system

These are the API endpoints we will be building and testing with Postman

  • GET / → The Home page
  • GET /api/recipes → List all recipes
  • GET /api/recipes/:id → Get a recipe by ID
  • POST /api/recipes → Create a recipe (with validation)

Set up file structure

Create a folder anywhere on your computer and name it

Open it in any text editor of your choice (I'm using VS Code to open mine), open the terminal, and run:

npm init -y
npm install dotenv express mysql2
npm install nodemon -D
Enter fullscreen mode Exit fullscreen mode

Add the “start” and "dev" scripts to package.json for convenience:

  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
Enter fullscreen mode Exit fullscreen mode

We will use MySQL Workbench
Download MYSQL workbench; we will be using the GUI in MYSQL workbench

Create the database in MySQL Workbench

CREATE DATABASE IF NOT EXISTS recipe_app;
USE recipe_app;

CREATE TABLE IF NOT EXISTS recipes (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  ingredients JSON NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

DATABASE CREATION

Create the Server


// server.js file

const express = require('express')
const mysql = require('mysql2/promise')
require('dotenv').config()

const server = express()
const PORT = process.env.PORT || 3000

server.use(express.json())

// Database setup
const db = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  connectTimeout: 10000,
})

// Check database connection
async function testConnection() {
  try {
    const connection = await db.getConnection()
    console.log('Connected to MySQL successfully')
    connection.release()
  } catch (error) {
    console.error('Database connection failed:', error.message)
  }
}

testConnection()

// Validation
function validateRecipe(data) {
  if (!data.title || typeof data.title !== 'string') {
    return 'Title is required and must be a string'
  }

  if (!Array.isArray(data.ingredients) || data.ingredients.length === 0) {
    return 'Ingredients must be a non-empty array'
  }

  return null
}

// Home route
server.get('/', (req, res) => {
  res.status(200).json({
    message: 'Home route',
  })
})

// Create recipe
server.post('/api/recipes', async (req, res) => {
  try {
    const error = validateRecipe(req.body)

    if (error) {
      return res.status(400).json({ error })
    }

    const { title, ingredients } = req.body

    const [result] = await db.execute(
      'INSERT INTO recipes (title, ingredients) VALUES (?, ?)',
      [title, JSON.stringify(ingredients)],
    )

    res.status(201).json({
      id: result.insertId,
      title,
      ingredients,
    })
  } catch (error) {
    console.error('Create recipe error:', error.message)
    res.status(500).json({ error: 'Database error' })
  }
})

// Get all recipes
server.get('/api/recipes', async (req, res) => {
  try {
    const [rows] = await db.execute('SELECT * FROM recipes')
    res.status(200).json(rows)
  } catch (error) {
    console.error('Fetch recipes error:', error.message)
    res.status(500).json({ error: 'Database error' })
  }
})

// Get single recipe
server.get('/api/recipes/:id', async (req, res) => {
  try {
    const [rows] = await db.execute('SELECT * FROM recipes WHERE id = ?', [
      req.params.id,
    ])

    if (rows.length === 0) {
      return res.status(404).json({
        error: 'Recipe not found',
      })
    }

    res.status(200).json(rows[0])
  } catch (error) {
    console.error('Fetch recipe error:', error.message)
    res.status(500).json({ error: 'Database error' })
  }
})

// Start server
server.listen(PORT, () => {
  console.log(`Recipe server running on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

Start the app using dev mode

npm run dev
Enter fullscreen mode Exit fullscreen mode

We will be testing the endpoint using Postman. If you are yet to have Postman on your PC, you can download it here

We will open Postman and create a collection named Recipe API and add a POST request to it. Enter the localhost URL; in our case, it is http://localhost:3000/api/recipes. Click on the Body, click on raw as well, and choose JSON

Paste this in it

{
  "title": "Pasta",
  "ingredients": ["noodles", "tomato sauce"]
}
Enter fullscreen mode Exit fullscreen mode

This will be the result

{
  "id": 3,
  "title": "Pasta",
  "ingredients": ["noodles", "tomato sauce"]
}
Enter fullscreen mode Exit fullscreen mode

Note: The id in your case might differ; in this case, it means I have created 3 entries

Dockerfile (The Simpler Version)

Here's the simplest version of the Dockerfile we can write

FROM node:24-slim

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

EXPOSE 3000

CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

Comprehending the Basic Dockerfile
Let's proceed line by line.

FROM node:24-slim

  • Definition: A base image is the starting point for any Dockerfile. An existing image that offers an operating system and, if desired, language runtimes or libraries is called a base image.
  • Here, we make use of the slim version of the official Node.js image, version 24.
  • Why is it slim? It is smaller, has fewer packages, and has fewer vulnerabilities because it simply has the necessities.
  • Trade-off: There might not be debugging tools like bash or curl. When troubleshooting, you might need to temporarily install these.

WORKDIR /app

  • Definition: Configures the container's working directory. This is where all subsequent commands execute.
  • Why: Prevents commands from executing in unexpected places. Consider it a single "change directory" to ensure that everything is in order.

COPY package*.json ./

  • Definition: This command copies files into the image from your host.
  • Why should package files be copied first? Docker uses layers to construct images. Docker can reuse the cached npm ci layer if dependencies remain unchanged. Rebuilds happen more quickly as a result.
  • If you copy every file first, each code change will invalidate the cache and require a complete reinstall.

Execute npm ci

  • Definition: During build time, a command is executed inside the image.
  • Dependencies are installed here.
  • Note: This also installs development dependencies by default, which enlarges the image.

COPY . .

  • Definition: Makes copies of the remaining program code.
  • Risk: You could copy local node_modules, logs, or secrets if you don't have a.dockerignore.

EXPOSE 3000

  • Definition: Records the port that the application is listening on.
  • Crucial: The port is not published by this. When starting the container, -p is still required.
  • Why: Assists other programs in determining which port to map, such as Docker Compose or Kubernetes.

CMD ["npm", "start"]

  • Definition: Specifies the command that is used by default when the container launches.
  • Here: Launches the application.
  • Note: The container operates but accomplishes nothing if it is left out.

The Better version of the Dockerfile (Ready for Production)

Let's now make it safe and effective:

FROM node:24-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY . .

RUN useradd -D appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 3000

CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

npm ci --omit=dev && npm cache clean --force

  • Installs only production dependencies using the exact versions specified in package-lock.json, ensuring consistent and reproducible builds.
  • Benefit: Faster, reliable Docker builds, smaller images, reduced attack surface, and consistent dependency versions across development, CI/CD, and production.
  • Consequence if ignored: Dependency versions may differ between builds, resulting in unpredictable deployments. Development dependencies may also end up in production, increasing image size and potential security vulnerabilities.

Non-root user

RUN useradd -D appuser && chown -R appuser:appuser /app
USER appuser
Enter fullscreen mode Exit fullscreen mode
  • Issue: By default, containers operate as root. The attacker has root within the container if it is compromised.
  • Solution: Use adduser -D (-D = disabled login, bypasses interactive prompts and home dir construction for Docker builds) to create a dedicated user with restricted access.
  • Benefit: Damage is limited even if the software is abused.
  • Trade-off: Without further settings, certain activities (such as binding to privileged ports <1024) won't function.

Alpine base image

  • Since the alpine base image is smaller than the slim base image, we make use of it.
  • Benefits: Faster pulls and a smaller attack surface.
  • Reduced tooling within the container is a trade-off.

.dockerignore

Create a .dockerignore file:

node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
Enter fullscreen mode Exit fullscreen mode
  • Why? Because it stops extraneous files from being copied into the image.
  • If ignored, the images get bigger, slower, and might unintentionally contain local build artifacts or secrets.

Build and Run

docker build -t recipe-app:v1.0.0 .
docker run -p 3000:3000 recipe-app:v1.0.0
Enter fullscreen mode Exit fullscreen mode
  • -t tags the image.
  • -p connects container port 3000 to host port 3000.
  • The application operates without -p; however, it cannot be accessed from the outside.

Test the app again following what we did above.

Debugging

If there is no response from the container:

  1. Execute in the foreground:
docker run -p 3000:3000 recipe-app:v1.0.0
Enter fullscreen mode Exit fullscreen mode
  1. Check the logs
  2. Make sure that Express is bound to "0.0.0.0"
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Recipe server running on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

If not, it will only listen within the container.

Conclusion

In this tutorial, we've explored how to build a Node.js Dockerfile, starting with a basic setup and gradually improving it for production. We covered dependency management, image optimization, non-root users, and .dockerignore to make our container more secure, lightweight, and efficient.

The goal is to build Docker images that are consistent, reliable, and ready for deployment across different environments without the usual "it works on my machine" problems.

Top comments (0)