Jiwon Min Developer

Dockerizing Your Web Application for Consistent Development

Many developers have likely heard or said the phrase, “But it works on my machine!” at some point during collaboration. Subtle differences in operating systems, library versions, and configuration settings among developers can cause unexpected bugs. Moreover, new team members often spend a significant amount of time on complex development environment setup processes. This is a persistent problem that hampers productivity.

The technology that emerged to solve these problems is Docker. Docker packages an application and its dependencies into an isolated space called a Container, ensuring it runs identically in any environment. This minimizes discrepancies between development, testing, and production environments, fundamentally solving the “it only works on my machine” issue.

In this post, from the perspective of an experienced server engineer, I will explain why you should adopt Docker for your development environment. I will also provide a detailed, step-by-step guide to building a consistent and efficient development environment by creating a Dockerfile and docker-compose.yml for a simple Node.js application.

Dockerizing Your Web Application for Consistent Development

© AI Generated by Imagen 4.0


What is Docker?

Docker is an open-source platform that enables you to build, test, and deploy applications quickly. At the core of Docker is container technology. A container bundles an application’s code with all the dependencies it needs to run (libraries, system tools, runtime, etc.) into a single package called an Image.

  • Image: A read-only template containing everything needed to run an application, including code, runtime, system tools, libraries, and settings.
  • Container: A runnable instance of an image. You can create multiple containers from a single image, and each container runs in isolation from the host system and other containers.

Unlike virtual machines (VMs), which virtualize hardware and install a full guest OS, containers share the host system’s OS kernel and are isolated at the process level. This makes Docker containers significantly lighter, faster, and more portable than VMs.

Why Use Docker for Development?

Adopting Docker in your development environment offers several clear advantages:

  1. Environmental Consistency: By defining the development environment through a code-based specification called a Dockerfile, every team member can work in the exact same environment. This fundamentally prevents the “but it works on my machine…” problem.
  2. Dependency Isolation: Each project’s dependencies are completely isolated within its container. There’s no need to install specific versions of Node.js, Python, or databases on your local machine, thus avoiding dependency conflicts between projects.
  3. Fast Setup: New team members can clone a Git repository and run the entire development environment (web server, database, cache, etc.) instantly with a single command: docker-compose up. Complex installation guides are no longer necessary.
  4. Parity with Production: By using the same Docker image in both development and production, you can dramatically reduce bugs caused by environmental differences.

Hands-On: Dockerizing a Node.js Application

Now, let’s go through the step-by-step process of running a simple Node.js Express application in a Docker container environment.

First, create a server.js file for a simple web server and a package.json file in your project folder.

package.json

{
  "name": "docker-node-app",
  "version": "1.0.0",
  "description": "Simple Node.js app for Docker",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.17.1"
  }
}

server.js

'use strict';

const express = require('express');

const PORT = 8080;
const HOST = '0.0.0.0';

const app = express();
app.get('/', (req, res) => {
  res.send('Hello from Docker Container!');
});

app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);

Next, create a Dockerfile to define the Docker image that will run this application.

Dockerfile

# 1. Select the base image
FROM node:18-alpine

# 2. Create the application directory
WORKDIR /usr/src/app

# 3. Install application dependencies
# Use a wildcard (*) to copy both package.json and package-lock.json
COPY package*.json ./
RUN npm install

# 4. Copy app source code
COPY . .

# 5. Expose the port the application will use
EXPOSE 8080

# 6. Define the command to run when the container starts
CMD [ "npm", "start" ]

Here’s what each command means:

  • FROM: Specifies the base image to build from. node:18-alpine is a Node.js version 18 image based on the lightweight Alpine Linux.
  • WORKDIR: Sets the working directory inside the container. Subsequent COPY, RUN, and CMD instructions will be executed in this directory.
  • COPY: Copies files from the host into the container.
  • RUN: Executes commands inside the container. Here, it installs dependencies using npm install.
  • EXPOSE: Specifies the port that the container will expose to the outside.
  • CMD: Defines the default command to be executed when the container starts.

Now, run the following commands in your terminal to build the Docker image and run the container.

# 1. Build the Docker image (-t option gives the image a name and tag)
$ docker build -t my-node-app:1.0 .

# 2. Run a container using the built image
# -p 4000:8080 : Connects port 4000 on the host to port 8080 in the container
# -d : Runs the container in the background
$ docker run -p 4000:8080 -d my-node-app:1.0

Now, if you open http://localhost:4000 in your web browser, you will see the message “Hello from Docker Container!”

Orchestrating Services with Docker Compose

In a real-world project, a web application often needs to work alongside other services like a database or a cache server. To manage and connect multiple containers at once, we use Docker Compose.

Let’s create a docker-compose.yml file in the project root. This file will define two services: our web application (app) and a PostgreSQL database (db).

docker-compose.yml

version: '3.8'

services:
  app:
    build: .
    ports:
      - "4000:8080"
    volumes:
      - .:/usr/src/app
    depends_on:
      - db
    environment:
      - DATABASE_URL=postgres://user:password@db:5432/mydatabase

  db:
    image: postgres:14-alpine
    restart: always
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=mydatabase
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
  • services: Defines the containers to be managed. We have defined two services named app and db.
  • build: .: The app service builds its image using the Dockerfile in the current directory.
  • image: postgres:14-alpine: The db service uses the official PostgreSQL image published on Docker Hub.
  • ports: Maps ports between the host and the container.
  • volumes: Mounts a host path (or a Docker volume) to a path inside the container. This allows code changes to be reflected in the container in real-time and ensures database data persists even if the container is deleted.
  • environment: Sets environment variables to be used inside the container.
  • depends_on: Sets dependencies between services. The app service will start after the db service has started.

Now, running the following command in the terminal will start all the services defined in docker-compose.yml at once.

# Start all services in the background
$ docker-compose up -d

# Stop and remove all services and containers
$ docker-compose down

With the single command docker-compose up, you can now perfectly replicate a complex, multi-service application environment.

Conclusion

Docker is no longer an option but an essential technology for modern web development and server operations. By adopting Docker in the development environment, we can ensure consistency, isolate dependencies, and drastically shorten the onboarding process for new team members. This directly leads to improved development productivity and stable service operation.

By leveraging the Dockerfile and Docker Compose introduced today, I hope that in your next project, you’ll experience the convenience of a reproducible and portable development environment, instead of saying, “…but it works on my machine.”

References