Containerizing a Full-Stack Application with Docker Compose

A comprehensive guide to containerizing JavaScript full-stack applications

The Containerization Challenge

As modern web developers, we often work with complex applications consisting of multiple components: frontend interfaces, backend APIs, databases, caching layers, and more. Managing all these components consistently across different environments can be challenging. This is where Docker and Docker Compose come in.

In this weekend project, we'll apply George Polya's 4-step problem-solving method to containerize a full-stack JavaScript application.

Traditional vs. Containerized Development

flowchart TD subgraph "Traditional Development" A1[Local Development] --> B1["It works on my machine!"] B1 --> C1["Environment differences"] C1 --> D1["Deployment issues"] D1 --> E1["Debugging nightmares"] end subgraph "Containerized Development" A2[Containerized Environment] --> B2["Consistent environments"] B2 --> C2["Isolated dependencies"] C2 --> D2["Reliable deployments"] D2 --> E2["Simplified scaling"] end

Meet Our Sample Application

For this project, we'll be containerizing a MERN stack application (MongoDB, Express.js, React, Node.js) with the following components:

Application Architecture

flowchart LR Client[Browser] --> Frontend[React Frontend] Frontend --> Backend[Express.js API] Backend --> MongoDB[(MongoDB)] Backend --> Redis[(Redis Cache)]

Step 1: Understanding the Problem

Before we dive into containerization, let's understand what we're trying to accomplish and the challenges we'll need to overcome.

Goals of Containerization

Challenges to Address

Application Structure

Our sample application has the following file structure:

mern-app/
├── frontend/              # React frontend
│   ├── package.json
│   ├── public/
│   └── src/
├── backend/               # Express.js backend API
│   ├── package.json
│   ├── server.js
│   └── src/
└── docker-compose.yml     # We'll create this file

To successfully containerize this application, we'll need to:

  1. Create Dockerfiles for both frontend and backend services
  2. Set up appropriate container configurations for MongoDB and Redis
  3. Create a Docker Compose file to orchestrate all services
  4. Configure networking between containers
  5. Set up volume mappings for persistent data and development

Step 2: Devising a Plan

Now that we understand the problem, let's break it down into manageable steps:

Implementation Plan

  1. Create a Dockerfile for the frontend application
  2. Create a Dockerfile for the backend API
  3. Write the docker-compose.yml file to connect all services
  4. Configure environment variables for each service
  5. Set up volume mappings for development and persistence
  6. Configure networking between services
  7. Add development-specific configurations for hot reloading
  8. Test the containerized application
  9. Create documentation for running the application

Key Considerations

As we implement our plan, we need to consider:

Key Containerization Considerations Development Experience Code hot reloading Fast feedback loops Debugging capabilities Performance Efficient builds Optimized images Resource allocation Security Secure secrets handling Minimal permissions Updated base images Production Readiness Multi-stage builds Environment configs Health checks Portability Cross-platform concerns Consistent behaviors Clear documentation Data Management Persistent volumes Backup strategies Data migration

Step 3: Executing the Plan

Now let's implement our containerization plan step by step.

Dockerizing the Frontend

First, let's create a Dockerfile for our React frontend. We'll use a multi-stage build approach to keep our production image small and efficient.

Create a file at frontend/Dockerfile:

# Frontend Dockerfile - frontend/Dockerfile

# Build stage
FROM node:18-alpine AS build

# Set working directory
WORKDIR /app

# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci

# Copy application code
COPY . .

# Build the application
RUN npm run build

# Production stage
FROM nginx:alpine

# Copy built files from build stage to nginx
COPY --from=build /app/build /usr/share/nginx/html

# Add nginx configuration if needed
# COPY nginx.conf /etc/nginx/conf.d/default.conf

# Expose port 80
EXPOSE 80

# Start nginx
CMD ["nginx", "-g", "daemon off;"]

This Dockerfile uses a multi-stage build approach:

  1. First stage uses Node.js to build the React application
  2. Second stage uses Nginx to serve the static files
  3. Only the built assets are copied to the final image, keeping it small

Dockerizing the Backend

Next, let's create a Dockerfile for our Express.js backend API.

Create a file at backend/Dockerfile:

# Backend Dockerfile - backend/Dockerfile

# Base image
FROM node:18-alpine

# Set working directory
WORKDIR /app

# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci

# Copy application code
COPY . .

# Expose API port
EXPOSE 4000

# Start the application
CMD ["npm", "start"]

For the backend, we use a simpler approach since we're not building static assets. We:

  1. Use a Node.js Alpine image for a small footprint
  2. Copy and install dependencies first (for better caching)
  3. Copy the application code
  4. Define the default command to start the application

Creating Docker Compose Configuration

Now let's create the Docker Compose file to orchestrate all our services together.

Create a file at the root of the project named docker-compose.yml:

# docker-compose.yml

version: '3.8'

services:
  # Frontend service
  frontend:
    build: 
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "3000:80"  # Map host port 3000 to container port 80
    depends_on:
      - backend
    # For development, we can use a different setup with volume mounts (see below)

  # Backend API service
  backend:
    build: 
      context: ./backend
      dockerfile: Dockerfile
    ports:
      - "4000:4000"  # Map host port 4000 to container port 4000
    depends_on:
      - mongo
      - redis
    environment:
      - NODE_ENV=production
      - PORT=4000
      - MONGO_URI=mongodb://mongo:27017/mernapp
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      # Add other environment variables as needed

  # MongoDB service
  mongo:
    image: mongo:6.0
    ports:
      - "27017:27017"  # Map MongoDB port for external tools access
    volumes:
      - mongo-data:/data/db  # Persist MongoDB data
    environment:
      - MONGO_INITDB_DATABASE=mernapp
      # Add authentication if needed
      # - MONGO_INITDB_ROOT_USERNAME=admin
      # - MONGO_INITDB_ROOT_PASSWORD=password

  # Redis service
  redis:
    image: redis:alpine
    ports:
      - "6379:6379"  # Map Redis port for external tools access
    volumes:
      - redis-data:/data  # Persist Redis data

# Define named volumes for data persistence
volumes:
  mongo-data:
  redis-data:

This Docker Compose file:

  1. Defines four services: frontend, backend, mongo, and redis
  2. Sets up appropriate port mappings to access services from the host
  3. Configures dependencies between services
  4. Sets environment variables for connecting services together
  5. Creates named volumes for database persistence

Development Configuration

For development, we want hot reloading and real-time code updates. Let's create a development-specific Docker Compose file.

Create a file named docker-compose.dev.yml:

# docker-compose.dev.yml

version: '3.8'

services:
  # Development frontend configuration
  frontend:
    build: 
      context: ./frontend
      dockerfile: Dockerfile.dev  # We'll create this next
    ports:
      - "3000:3000"  # React development server port
    volumes:
      - ./frontend:/app  # Mount source code for hot reloading
      - /app/node_modules  # Exclude node_modules from volume mount
    environment:
      - NODE_ENV=development
      - REACT_APP_API_URL=http://localhost:4000  # Point to backend API
    depends_on:
      - backend

  # Development backend configuration
  backend:
    build: 
      context: ./backend
      dockerfile: Dockerfile.dev  # We'll create this next
    ports:
      - "4000:4000"
    volumes:
      - ./backend:/app  # Mount source code for hot reloading
      - /app/node_modules  # Exclude node_modules from volume mount
    environment:
      - NODE_ENV=development
      - PORT=4000
      - MONGO_URI=mongodb://mongo:27017/mernapp
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    depends_on:
      - mongo
      - redis

  # MongoDB service (same as production)
  mongo:
    image: mongo:6.0
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db
    environment:
      - MONGO_INITDB_DATABASE=mernapp

  # Redis service (same as production)
  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  mongo-data:
  redis-data:

Development Dockerfiles

Now let's create development-specific Dockerfiles for the frontend and backend.

Create a file at frontend/Dockerfile.dev:

# Frontend Development Dockerfile - frontend/Dockerfile.dev

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

# No need to copy the source code - we'll mount it as a volume
# COPY . .

EXPOSE 3000

CMD ["npm", "start"]

Create a file at backend/Dockerfile.dev:

# Backend Development Dockerfile - backend/Dockerfile.dev

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

# Install nodemon for hot reloading
RUN npm install -g nodemon

# No need to copy the source code - we'll mount it as a volume
# COPY . .

EXPOSE 4000

CMD ["nodemon", "server.js"]

These development Dockerfiles are optimized for development workflows:

  1. They don't copy source code (it will be mounted as a volume)
  2. Use standard npm install instead of npm ci for faster iteration
  3. The backend uses Nodemon to automatically restart on code changes
  4. The frontend uses React's built-in development server with hot module replacement

Environment Variables

For better management of environment variables, let's create .env files for each environment.

Create a file named .env.development:

# .env.development

# Frontend variables
REACT_APP_API_URL=http://localhost:4000

# Backend variables
PORT=4000
MONGO_URI=mongodb://mongo:27017/mernapp
REDIS_HOST=redis
REDIS_PORT=6379
NODE_ENV=development

Create a file named .env.production:

# .env.production

# Frontend variables
REACT_APP_API_URL=/api

# Backend variables
PORT=4000
MONGO_URI=mongodb://mongo:27017/mernapp
REDIS_HOST=redis
REDIS_PORT=6379
NODE_ENV=production

Then update the Docker Compose files to use these environment files:

# Update docker-compose.yml to use production env file
services:
  frontend:
    # ... other config
    env_file:
      - .env.production
  
  backend:
    # ... other config
    env_file:
      - .env.production
# Update docker-compose.dev.yml to use development env file
services:
  frontend:
    # ... other config
    env_file:
      - .env.development
  
  backend:
    # ... other config
    env_file:
      - .env.development

Step 4: Reviewing and Extending the Solution

Running the Application

Now that we've set up our containerized application, let's run it and test it.

For development mode:

# Start the development environment
docker-compose -f docker-compose.dev.yml up

# Or with detached mode
docker-compose -f docker-compose.dev.yml up -d

# View logs
docker-compose -f docker-compose.dev.yml logs -f

# Stop the environment
docker-compose -f docker-compose.dev.yml down

For production mode:

# Start the production environment
docker-compose up

# Or with detached mode
docker-compose up -d

# View logs
docker-compose logs -f

# Stop the environment
docker-compose down

Testing the Application

Once the application is running, you can access:

Optimizations and Extensions

Let's explore some ways to enhance our containerized application:

Production Optimization

For a more production-ready setup, we can add:

# Add health checks to docker-compose.yml
services:
  backend:
    # ... other config
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s
  
  mongo:
    # ... other config
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s

Make sure to add a health endpoint to your backend:

// Add to backend/server.js
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

Setting Up Reverse Proxy

In production, we often want to serve both frontend and backend from the same domain. Let's modify our frontend configuration to use Nginx as a reverse proxy.

Create a file named frontend/nginx.conf:

# frontend/nginx.conf
server {
    listen 80;
    
    # Serve frontend static files
    location / {
        root /usr/share/nginx/html;
        index index.html index.htm;
        try_files $uri $uri/ /index.html;
    }
    
    # Proxy API requests to backend
    location /api {
        proxy_pass http://backend:4000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Then update the frontend Dockerfile to use this configuration:

# Update frontend/Dockerfile
# ...
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# ...

Docker Compose Override for Local Development

Instead of maintaining a separate development file, we can use Docker Compose's override feature:

Keep the main docker-compose.yml for common settings and create docker-compose.override.yml for development-specific settings:

# docker-compose.override.yml

version: '3.8'

services:
  frontend:
    build: 
      context: ./frontend
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development

  backend:
    build: 
      context: ./backend
      dockerfile: Dockerfile.dev
    volumes:
      - ./backend:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
    command: ["nodemon", "server.js"]

With this approach, simply running docker-compose up will use the development configuration automatically, while docker-compose -f docker-compose.yml -f docker-compose.prod.yml up can be used for production.

Adding More Services

We can extend our application with additional services:

# Add Adminer for database management
services:
  # ... other services
  
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    depends_on:
      - mongo

Security Considerations

For a production environment, we should implement several security measures:

# Secure MongoDB with authentication
services:
  mongo:
    # ... other config
    environment:
      - MONGO_INITDB_ROOT_USERNAME=admin
      - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
  
  backend:
    # ... other config
    environment:
      - MONGO_URI=mongodb://admin:${MONGO_PASSWORD}@mongo:27017/mernapp?authSource=admin

Using Docker secrets for sensitive information:

# Using Docker secrets for sensitive data
services:
  backend:
    # ... other config
    secrets:
      - mongo_password
    environment:
      - MONGO_PASSWORD_FILE=/run/secrets/mongo_password

secrets:
  mongo_password:
    file: ./secrets/mongo_password.txt

CI/CD Integration

To set up continuous integration and deployment, we can create configuration files for popular CI/CD platforms.

Example GitHub Actions workflow in .github/workflows/docker-build.yml:

name: Build and Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Build and start containers
      run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up -d
    
    - name: Run tests
      run: docker-compose -f docker-compose.yml -f docker-compose.test.yml run backend npm test
    
    - name: Stop containers
      run: docker-compose -f docker-compose.yml -f docker-compose.test.yml down

Real-world Application and Considerations

Scaling Considerations

In a production environment, you might need to scale different components of your application differently. While Docker Compose is primarily designed for single-host deployments, understanding these principles will help you transition to orchestration tools like Kubernetes when necessary.

Scaling Options

flowchart LR subgraph "Horizontal Scaling" direction TB H1[Instance 1] H2[Instance 2] H3[Instance 3] LB[Load Balancer] LB --> H1 LB --> H2 LB --> H3 end subgraph "Vertical Scaling" direction TB V1[Single Larger Instance] end

You can scale services with Docker Compose like this:

# Scale the backend to 3 instances
docker-compose up -d --scale backend=3

However, this requires additional configuration for ports and load balancing.

Real-world Examples

E-commerce Platform

For an e-commerce application, you might extend this stack with:

Content Management System

For a CMS, you might add:

Industry Analogies

Containerization with Docker Compose is like a modular kitchen setup:

When a new chef (developer) joins the kitchen, they don't need to buy and set up each appliance individually—they just need to follow the layout plan (docker-compose.yml) to recreate the entire working kitchen.

Troubleshooting Common Issues

Container Connectivity Issues

If services can't communicate with each other:

Volume Permissions

If you encounter permission issues with mounted volumes:

Build and Runtime Issues

Debugging Container Internals

# Access a running container's shell
docker-compose exec service_name sh

# View container logs
docker-compose logs -f service_name

# Inspect container details
docker inspect container_id

Practice Exercises

Exercise 1: Basic Setup

Take a simple Express.js application and containerize it with Docker Compose:

  1. Create a Dockerfile for the Express application
  2. Add a MongoDB service
  3. Configure the connection between the two
  4. Create a docker-compose.yml file
  5. Run and test the application

Exercise 2: Full-stack Application

Containerize a personal project or a sample full-stack application:

  1. Set up Dockerfiles for frontend and backend
  2. Configure necessary databases and services
  3. Set up development environment with hot reloading
  4. Configure production environment with optimized settings
  5. Implement health checks and monitoring

Exercise 3: Extend the Application

Enhance the containerized application:

  1. Add an Elasticsearch service for search functionality
  2. Implement a Redis cache for frequently accessed data
  3. Set up scheduled backup of your database volumes
  4. Add monitoring with Prometheus and Grafana
  5. Configure HTTPS with a reverse proxy

Conclusion

Containerizing a full-stack application with Docker Compose provides numerous benefits:

By following George Polya's 4-step problem-solving approach, we've:

  1. Understood the problem by identifying our goals and challenges
  2. Devised a plan by breaking down the containerization process into manageable steps
  3. Executed the plan by creating Dockerfiles and Docker Compose configurations
  4. Reviewed the solution by testing, optimizing, and extending our containerized application

This weekend project has equipped you with the skills to containerize any full-stack JavaScript application, making your development process more efficient and your deployments more reliable.

Additional Resources