- Introduction
- Prerequisites
- These are the API endpoints we will buiding and testing it with our Postman
- Set up file structure
- Create the database in MySQL Workbench
- Create the Server
- Start the app using dev mode
- Dockerfile (The Simpler Version)
- FROM node:24-slim
- WORKDIR /app
- COPY package*.json ./
- Execute npm ci
- COPY . .
- EXPOSE 3000
- CMD ["npm", "start"]
- The Better version of the Dockerfile (Ready for Production)
- npm ci --omit=dev && npm cache clean --force
- Non-root user
- Alpine base image
- .dockerignore
- Build and Run
- Debugging
- Conclusion
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 Add the “start” and "dev" scripts to package.json for convenience:
"scripts": { "start": "node server.js", "dev": "nodemon server.js" } 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 ); 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}`) }) Start the app using dev mode
npm run dev 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"] } This will be the result
{ "id": 3, "title": "Pasta", "ingredients": ["noodles", "tomato sauce"] } 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"] 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
bashorcurl. 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 cilayer 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, orsecretsif 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"] 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 - 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 - 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 - -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:
- Execute in the foreground:
docker run -p 3000:3000 recipe-app:v1.0.0 - Check the logs
- 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}`) }) 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.


