Welcome to Chapter 12: Your First Full-Stack Docker Project!

Alright, superstar! You’ve journeyed through the Docker universe, mastering individual containers, building custom images, and even orchestrating multi-container setups. Now, it’s time to bring all that knowledge together for a grand finale: containerizing a complete web application with both a frontend and a backend!

This chapter isn’t just about learning; it’s about doing. We’ll build a simple web application from scratch, define its Docker images, and then use docker compose to bring the entire ecosystem to life. This hands-on project will solidify your understanding of how real-world applications leverage Docker for development, testing, and deployment. Get ready to feel like a true Docker pro!

To make the most of this chapter, ensure you’re comfortable with:

  • Creating Dockerfiles for different application types.
  • Understanding Docker images, containers, and volumes.
  • The basics of docker compose for defining and running multi-container applications.
  • Docker networking fundamentals.

Why Containerize a Full-Stack App?

Imagine you’re building a complex application with a Node.js API backend and a React frontend. Without Docker, you’d need to:

  1. Install Node.js and its dependencies for the backend.
  2. Install all frontend build tools (like Node.js again for npm/yarn, webpack, etc.).
  3. Ensure compatible versions of everything.
  4. Configure separate servers for each.

It’s a lot of setup, right? And what if your teammate uses a different operating system or has different versions installed? That’s where “it works on my machine” nightmares begin!

With Docker, we package each part of our application (frontend, backend) into its own isolated, reproducible container. This means:

  • Consistency: Your app runs the same way everywhere.
  • Isolation: Dependencies for the frontend won’t conflict with the backend.
  • Portability: Easily move your entire application between environments (development, staging, production).
  • Simplified Setup: New developers can get the entire app running with a single docker compose up command.

It’s like giving each part of your application its own perfectly configured, tiny computer, and then teaching these computers how to talk to each other!

Core Concepts: Our Simple Web App Architecture

For this project, we’ll create a super simple web application consisting of two main components:

  1. Backend (Node.js Express API): This will be a small API server written in Node.js using the Express framework. It will expose a single endpoint that simply returns a “Hello from Backend!” message.
  2. Frontend (Static HTML served by Nginx): This will be a basic HTML page that makes a request to our backend API and displays the response. We’ll use Nginx, a powerful and popular web server, to serve our static HTML files.

Here’s how they’ll interact:

  • The Frontend container will run Nginx, serving index.html.
  • When index.html loads in your browser, it will contain JavaScript that tries to fetch data from the Backend API.
  • The Backend container will run our Node.js Express application, listening for requests.

We’ll use docker compose to define and manage these two services, ensuring they can communicate with each other.

A Peek at Our Project Structure

To keep things organized and follow best practices, our project will have a clear directory structure:

my-fullstack-app/
├── backend/                  # Contains backend code and its Dockerfile
│   ├── app.js
│   ├── package.json
│   └── Dockerfile
├── frontend/                 # Contains frontend code and its Dockerfile/Nginx config
│   ├── index.html
│   ├── nginx.conf
│   └── Dockerfile
└── docker-compose.yml        # Orchestrates both services

Each service gets its own folder, containing its specific application code and its Dockerfile. This makes our docker-compose.yml cleaner and easier to manage.

Step-by-Step Implementation: Building Our Application

Let’s start by setting up our project directory and creating the necessary files.

Step 1: Create the Project Directory

Open your terminal or command prompt and create a new directory for our project:

mkdir my-fullstack-app
cd my-fullstack-app

Now you’re inside the my-fullstack-app directory. This will be the root of our project.

Step 2: Set Up the Backend Service

First, let’s create the backend directory and its files.

mkdir backend
cd backend

2.1: package.json for Node.js Backend

Every Node.js project needs a package.json file to manage its dependencies. We’ll only need express for our simple API.

Create a file named package.json inside the backend directory:

// backend/package.json
{
  "name": "backend-api",
  "version": "1.0.0",
  "description": "Simple Node.js Express API",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

Explanation:

  • "name": "backend-api": A unique name for our backend package.
  • "version": "1.0.0": Standard versioning.
  • "description": A brief description.
  • "main": "app.js": Tells Node.js which file to run as the main entry point.
  • "scripts": { "start": "node app.js" }: Defines a script to start our application. We’ll use npm start inside the container.
  • "dependencies": { "express": "^4.18.2" }: Declares express as a dependency. The ^ means “compatible with version 4.18.2 or higher, but not 5.0.0 or higher”. (As of late 2025, Express 4.x is still widely used and stable).

2.2: app.js (Our Node.js Express API)

Now, let’s write the actual API code. This will be a very minimal Express app.

Create a file named app.js inside the backend directory:

// backend/app.js
const express = require('express');
const app = express();
const port = 3000; // The port our Node.js app will listen on

// Enable CORS for frontend to access this API
// In a real app, you'd restrict this to specific origins.
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*'); // Allows all origins for simplicity
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next();
});

// Define a simple API endpoint
app.get('/api', (req, res) => {
  res.json({ message: 'Hello from Dockerized Backend!' });
});

// Start the server
app.listen(port, () => {
  console.log(`Backend API listening at http://localhost:${port}`);
});

Explanation:

  • const express = require('express');: Imports the Express library.
  • const app = express();: Creates an Express application instance.
  • const port = 3000;: Our backend server will listen on port 3000 inside its container.
  • app.use((req, res, next) => { ... });: This is a middleware to handle CORS (Cross-Origin Resource Sharing). Because our frontend will be served from a different “origin” (e.g., localhost:80 or localhost:8080) than our backend (localhost:3000), the browser will block the request unless the backend explicitly allows it. For simplicity, we’re allowing all origins (*). Important: In a production environment, you would replace * with the specific domain(s) your frontend is served from (e.g., https://your-frontend-domain.com).
  • app.get('/api', ...);: Defines a GET endpoint at /api. When accessed, it sends a JSON response.
  • app.listen(port, ...);: Starts the Express server and makes it listen for incoming requests on the specified port.

2.3: Dockerfile for the Backend

Now, let’s create the Dockerfile that will build our backend image.

Still in the backend directory, create a file named Dockerfile:

# backend/Dockerfile
# Use a lightweight Node.js base image
FROM node:20-alpine AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy package.json and package-lock.json (if exists) first
# This allows Docker to cache node_modules if these files don't change
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy the rest of the application code
COPY . .

# Expose the port the app runs on
EXPOSE 3000

# Command to run the application
CMD ["npm", "start"]

Explanation (line by line):

  • FROM node:20-alpine AS builder: We start with the node:20-alpine image. alpine is a very small Linux distribution, making our image lightweight. 20 is a recent LTS (Long Term Support) version of Node.js as of late 2025. AS builder is part of a multi-stage build, which we’re using here for clarity, though for this simple app, a single stage would also work.
  • WORKDIR /app: Sets the default working directory inside the container to /app. All subsequent commands will run from this directory unless specified otherwise.
  • COPY package*.json ./: Copies package.json and package-lock.json (if you have one) from your host machine into the /app directory in the container. We do this before copying the rest of the code so that Docker can cache the npm install step. If only app.js changes, Docker won’t need to reinstall dependencies.
  • RUN npm install: Executes npm install inside the container to download and install all the Node.js dependencies defined in package.json.
  • COPY . .: Copies all remaining files from your current directory (./backend) on the host to the /app directory inside the container.
  • EXPOSE 3000: Informs Docker that the container listens on port 3000 at runtime. This is purely documentation; it doesn’t actually publish the port. Port publishing is handled by docker compose or docker run.
  • CMD ["npm", "start"]: Specifies the command to run when the container starts. This executes our start script defined in package.json.

Now, navigate back to the root of our project:

cd .. # You should now be in 'my-fullstack-app'

Step 3: Set Up the Frontend Service

Next, let’s create the frontend directory and its files.

mkdir frontend
cd frontend

3.1: index.html (Our Static Frontend)

This will be a simple HTML page with some JavaScript to fetch data from our backend.

Create a file named index.html inside the frontend directory:

<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Docker Fullstack App</title>
    <style>
        body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background-color: #f0f2f5; color: #333; }
        .container { background-color: white; padding: 40px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; }
        h1 { color: #007bff; margin-bottom: 20px; }
        p { font-size: 1.1em; margin-bottom: 30px; }
        button { background-color: #28a745; color: white; border: none; padding: 12px 25px; border-radius: 5px; cursor: pointer; font-size: 1em; transition: background-color 0.3s ease; }
        button:hover { background-color: #218838; }
        #backend-message { margin-top: 25px; font-size: 1.2em; font-weight: bold; color: #666; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Welcome to Your Dockerized App!</h1>
        <p>This is the frontend. Let's talk to the backend!</p>
        <button onclick="fetchBackendData()">Fetch Backend Data</button>
        <div id="backend-message">Click the button to get a message from the backend.</div>
    </div>

    <script>
        async function fetchBackendData() {
            const messageDiv = document.getElementById('backend-message');
            messageDiv.textContent = 'Fetching data...';
            try {
                // IMPORTANT: When running with Docker Compose, 'backend' is the service name
                // and it resolves to the backend container's IP address.
                // The port is the internal port the backend app listens on (3000).
                const response = await fetch('http://backend:3000/api');
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                const data = await response.json();
                messageDiv.textContent = `Backend Says: "${data.message}"`;
            } catch (error) {
                console.error('Error fetching from backend:', error);
                messageDiv.textContent = `Error: Could not connect to backend. Is it running? (${error.message})`;
            }
        }
    </script>
</body>
</html>

Explanation:

  • This is a standard HTML5 page with some basic styling.
  • The key part is the fetchBackendData() JavaScript function.
  • fetch('http://backend:3000/api'): This is crucial! When containers are part of a docker compose network, they can refer to each other by their service names as hostnames. So, backend here resolves to the IP address of our backend service container. We then specify the internal port (3000) that the Node.js app is listening on.
  • The try...catch block handles potential network errors.

3.2: nginx.conf (Nginx Configuration)

Nginx needs a configuration file to know how to serve our index.html.

Create a file named nginx.conf inside the frontend directory:

# frontend/nginx.conf
events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    server {
        listen 80; # Nginx will listen on port 80 inside the container
        server_name localhost;

        root /usr/share/nginx/html; # Directory where our static files will be

        location / {
            try_files $uri $uri/ /index.html; # Serve index.html for all requests
        }
    }
}

Explanation:

  • events { ... }: Basic Nginx event configuration.
  • http { ... }: The main HTTP server block.
  • server { ... }: Defines a virtual server.
  • listen 80;: Nginx will listen for HTTP requests on port 80 inside its container.
  • root /usr/share/nginx/html;: This tells Nginx where to find the static files it should serve. We’ll copy our index.html to this location in the Dockerfile.
  • location / { ... }: This block defines how Nginx handles requests to the root path (/).
  • try_files $uri $uri/ /index.html;: This is a common Nginx pattern. It tries to serve the requested URI ($uri), then the URI as a directory ($uri/), and if neither is found, it falls back to serving /index.html. This is useful for single-page applications.

3.3: Dockerfile for the Frontend

Finally, let’s create the Dockerfile for our frontend, which will use Nginx.

Still in the frontend directory, create a file named Dockerfile:

# frontend/Dockerfile
# Use the official Nginx base image
FROM nginx:stable-alpine

# Remove default Nginx configuration
RUN rm /etc/nginx/conf.d/default.conf

# Copy our custom Nginx configuration
COPY nginx.conf /etc/nginx/conf.d/my-app.conf

# Copy our static frontend files to the Nginx web root
COPY . /usr/share/nginx/html

# Expose the port Nginx is listening on
EXPOSE 80

# Nginx is already configured to run as the default CMD in its base image
# CMD ["nginx", "-g", "daemon off;"] # This is usually not needed as base image provides it

Explanation (line by line):

  • FROM nginx:stable-alpine: We use the official Nginx image based on Alpine Linux for a small footprint. stable-alpine is a good choice for production.
  • RUN rm /etc/nginx/conf.d/default.conf: The Nginx base image comes with a default configuration file. We remove it so it doesn’t conflict with ours.
  • COPY nginx.conf /etc/nginx/conf.d/my-app.conf: Copies our custom nginx.conf into the Nginx configuration directory. Nginx automatically loads .conf files from this directory.
  • COPY . /usr/share/nginx/html: Copies all files from our frontend directory (which includes index.html) to the Nginx default web root directory inside the container.
  • EXPOSE 80: Informs Docker that the Nginx server listens on port 80.

Now, navigate back to the root of our project:

cd .. # You should now be in 'my-fullstack-app'

Step 4: Define Services with docker-compose.yml

We’ve created the individual components. Now, let’s use docker compose to bring them all together.

In the root my-fullstack-app directory, create a file named docker-compose.yml:

# docker-compose.yml
version: '3.8' # Using a recent stable Docker Compose file format version

services:
  backend:
    build: ./backend # Tells Docker Compose to build an image using the Dockerfile in the ./backend directory
    ports:
      - "3000:3000" # Host_Port:Container_Port - Map host port 3000 to container port 3000
    # volumes:
    #   - ./backend:/app # Optional: Mount local backend code into container for live reloading during development
    restart: always # Always restart the backend service if it stops

  frontend:
    build: ./frontend # Tells Docker Compose to build an image using the Dockerfile in the ./frontend directory
    ports:
      - "80:80" # Host_Port:Container_Port - Map host port 80 to container port 80
      - "8080:80" # Optional: Map another host port 8080 to container port 80 for alternative access
    depends_on:
      - backend # Ensures the backend container starts before the frontend container
    restart: always # Always restart the frontend service if it stops

Explanation (line by line):

  • version: '3.8': Specifies the Docker Compose file format version. 3.8 (or 3.9 if you prefer the absolute latest stable for 2025) is a good, modern choice that includes many features.
  • services:: This is the top-level key where you define all the containers (services) that make up your application.
  • backend:: Defines a service named backend. This name is important because the frontend will use it for communication.
    • build: ./backend: Instead of pulling a pre-existing image, this tells docker compose to build an image using the Dockerfile found in the ./backend directory.
    • ports: - "3000:3000": Maps port 3000 on your host machine to port 3000 inside the backend container. This allows you to access the backend API directly from your browser or other tools via http://localhost:3000.
    • # volumes: - ./backend:/app: This line is commented out but is a fantastic tip for development! If uncommented, it would mount your local backend directory into the container’s /app directory. This means any changes you make to app.js on your host machine would immediately reflect inside the running container (often requiring a restart of the Node.js process within the container, which tools like nodemon can automate). For now, we’ll keep it simple and build the image directly.
    • restart: always: This policy ensures that if the backend container stops for any reason (e.g., a crash), Docker Compose will automatically restart it.
  • frontend:: Defines a service named frontend.
    • build: ./frontend: Builds the frontend image using the Dockerfile in the ./frontend directory.
    • ports: - "80:80" - "8080:80": Maps host port 80 to container port 80 (standard HTTP). We also add 8080:80 as an alternative, in case port 80 is already in use on your host machine by another application. You can then access the frontend via http://localhost or http://localhost:8080.
    • depends_on: - backend: This is a crucial instruction. It tells docker compose that the frontend service depends on the backend service. This means docker compose will try to start the backend container before starting the frontend container. Important: depends_on only guarantees the start order, not that the backend application inside the container is fully ready to accept connections. For robust production setups, you’d add health checks.
    • restart: always: Similar to the backend, ensures the frontend service restarts automatically.

Step 5: Build and Run Our Application!

Now for the exciting part! Make sure you are in the my-fullstack-app root directory (where docker-compose.yml is located).

5.1: Build the Images

First, we need to build the Docker images for our backend and frontend services.

docker compose build

What’s happening:

  • docker compose build reads your docker-compose.yml file.
  • For each service (backend and frontend) that has a build instruction, it navigates to the specified directory (./backend or ./frontend).
  • It then executes the Dockerfile in that directory, building a Docker image for that service.
  • You’ll see output for each step of the Dockerfile being executed for both services.

5.2: Start the Containers

Once the images are built, we can start our services:

docker compose up -d

What’s happening:

  • docker compose up: Starts all the services defined in docker-compose.yml.
  • -d: Runs the containers in “detached” mode (in the background), so your terminal prompt remains free.
  • docker compose will create a default network for your project, allowing the backend and frontend containers to communicate using their service names.

You should see output indicating that the services are being created and started.

5.3: Verify Everything is Running

You can check the status of your running containers:

docker compose ps

You should see both backend and frontend services listed with a State of Up.

NAME                COMMAND                  SERVICE             STATUS              PORTS
my-fullstack-app-backend-1   "docker-entrypoint.s…"   backend             running             0.0.0.0:3000->3000/tcp
my-fullstack-app-frontend-1  "/docker-entrypoint.…"   frontend            running             0.0.0.0:80->80/tcp, 0.0.0.0:8080->80/tcp

(Note: Container names might vary slightly, but the service names and ports should be consistent.)

5.4: Access Your Application!

Open your web browser and navigate to:

  • Frontend: http://localhost or http://localhost:8080
  • Backend API (direct access): http://localhost:3000/api (you should see {"message":"Hello from Dockerized Backend!"})

When you open http://localhost (or http://localhost:8080), you should see our simple frontend page. Click the “Fetch Backend Data” button. If everything is set up correctly, you should see the message: “Backend Says: “Hello from Dockerized Backend!”” appear on the page!

Congratulations! You’ve successfully containerized and deployed a full-stack web application using Docker Compose!

Step 6: Stopping and Cleaning Up

When you’re done, it’s good practice to stop and remove your containers.

To stop the running containers (but keep the images):

docker compose stop

To stop and remove the containers, networks, and volumes (but keep the images):

docker compose down

To remove everything, including the images built for this project:

docker compose down --rmi all

Explanation:

  • docker compose stop: Gracefully stops the containers.
  • docker compose down: Stops and removes the containers and the default network created by docker compose.
  • docker compose down --rmi all: Does down and also removes all images that were built for this project (our backend and frontend images).

Mini-Challenge: Enhance the Backend!

You’ve built a solid foundation. Now, let’s make it a tiny bit more interactive!

Challenge:

  1. Modify the backend/app.js file to include a new endpoint: /api/greeting.
  2. This new endpoint should accept a name query parameter (e.g., /api/greeting?name=Alice).
  3. It should return a JSON response like { "greeting": "Hello, Alice from Dockerized Backend!" }. If no name is provided, it should default to “Guest”.
  4. Update the frontend/index.html to have an input field where the user can type their name, and a button to “Greet Backend”. When clicked, it should fetch from the new /api/greeting endpoint with the provided name and display the response.

Hint:

  • For the backend, you can access query parameters using req.query.name in Express.
  • After modifying app.js and index.html, remember that you’ll need to rebuild the images and restart the services for changes to take effect.
    • For backend/app.js changes, you’ll need docker compose build backend then docker compose up -d backend.
    • For frontend/index.html changes, you’ll need docker compose build frontend then docker compose up -d frontend.
    • Or, simply docker compose build && docker compose up -d to rebuild and restart everything.

What to observe/learn:

  • How easy it is to update individual services without affecting others.
  • The power of container communication using service names.
  • The iterative development cycle with Docker.

(Pause here, try the challenge on your own!)

Click here for a possible solution to the Mini-Challenge!

Solution for backend/app.js:

// backend/app.js
const express = require('express');
const app = express();
const port = 3000;

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next();
});

app.get('/api', (req, res) => {
  res.json({ message: 'Hello from Dockerized Backend!' });
});

// New endpoint for greeting
app.get('/api/greeting', (req, res) => {
  const name = req.query.name || 'Guest'; // Get name from query, default to Guest
  res.json({ greeting: `Hello, ${name} from Dockerized Backend!` });
});

app.listen(port, () => {
  console.log(`Backend API listening at http://localhost:${port}`);
});

Solution for frontend/index.html:

<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Docker Fullstack App</title>
    <style>
        body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background-color: #f0f2f5; color: #333; }
        .container { background-color: white; padding: 40px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; }
        h1 { color: #007bff; margin-bottom: 20px; }
        p { font-size: 1.1em; margin-bottom: 30px; }
        button { background-color: #28a745; color: white; border: none; padding: 12px 25px; border-radius: 5px; cursor: pointer; font-size: 1em; transition: background-color 0.3s ease; margin: 5px; }
        button:hover { background-color: #218838; }
        input[type="text"] { padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 5px; width: 200px; }
        #backend-message, #greeting-message { margin-top: 25px; font-size: 1.2em; font-weight: bold; color: #666; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Welcome to Your Dockerized App!</h1>
        <p>This is the frontend. Let's talk to the backend!</p>
        <button onclick="fetchBackendData()">Fetch General Backend Data</button>
        <div id="backend-message">Click the button to get a message from the backend.</div>

        <hr style="width: 80%; margin: 30px 0;">

        <h2>Say Hello!</h2>
        <input type="text" id="nameInput" placeholder="Enter your name">
        <button onclick="greetBackend()">Greet Backend</button>
        <div id="greeting-message">Enter a name and greet the backend!</div>
    </div>

    <script>
        async function fetchBackendData() {
            const messageDiv = document.getElementById('backend-message');
            messageDiv.textContent = 'Fetching general data...';
            try {
                const response = await fetch('http://backend:3000/api');
                if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); }
                const data = await response.json();
                messageDiv.textContent = `Backend Says: "${data.message}"`;
            } catch (error) {
                console.error('Error fetching from backend /api:', error);
                messageDiv.textContent = `Error: Could not connect to /api. (${error.message})`;
            }
        }

        async function greetBackend() {
            const name = document.getElementById('nameInput').value;
            const greetingDiv = document.getElementById('greeting-message');
            greetingDiv.textContent = 'Sending greeting...';
            try {
                const response = await fetch(`http://backend:3000/api/greeting?name=${encodeURIComponent(name)}`);
                if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); }
                const data = await response.json();
                greetingDiv.textContent = `Backend Says: "${data.greeting}"`;
            } catch (error) {
                console.error('Error fetching from backend /api/greeting:', error);
                greetingDiv.textContent = `Error: Could not connect to /api/greeting. (${error.message})`;
            }
        }
    </script>
</body>
</html>

Steps to apply the solution:

  1. Replace the content of backend/app.js with the updated code.
  2. Replace the content of frontend/index.html with the updated code.
  3. In your terminal (from the my-fullstack-app root directory):
    docker compose build && docker compose up -d
    
  4. Refresh your browser at http://localhost (or http://localhost:8080). You should now see the new input field and button. Test it out!

Common Pitfalls & Troubleshooting

Even for simple projects, Docker can sometimes throw curveballs. Here are a few common issues and how to tackle them:

  1. “Container exited with code 1” / App not starting:

    • Symptom: Your docker compose ps shows a service with Exited status.
    • Cause: Often, your application code itself has an error, or a dependency is missing, preventing it from starting correctly.
    • Fix: Check the container logs! Run docker compose logs <service_name> (e.g., docker compose logs backend). This will show you the output your application produced, which often contains error messages. For Node.js, it might be a missing node_modules or a syntax error in app.js.
  2. Frontend can’t reach Backend (Failed to fetch, CORS errors):

    • Symptom: Your browser’s developer console shows network errors (e.g., ERR_CONNECTION_REFUSED, CORS policy: No 'Access-Control-Allow-Origin' header is present).
    • Cause 1 (Network): The frontend container can’t resolve the backend service name or the backend port is incorrect.
    • Fix 1: Double-check http://backend:3000/api in index.html. Ensure backend is the correct service name in docker-compose.yml and 3000 is the port your Node.js app is actually listening on. You can also try docker exec -it <frontend_container_id> ping backend to verify network connectivity.
    • Cause 2 (CORS): The backend isn’t sending the correct Access-Control-Allow-Origin header.
    • Fix 2: Verify your app.js includes the CORS middleware. In a development environment, res.header('Access-Control-Allow-Origin', '*') is common. In production, be more specific.
  3. Port Conflicts (Bind for 0.0.0.0:80 failed: port is already allocated):

    • Symptom: docker compose up fails with an error indicating a port is already in use.
    • Cause: Another application on your host machine (or another Docker container) is already using one of the ports you’re trying to map (e.g., 80 or 3000).
    • Fix:
      • Stop the conflicting application.
      • Change the host port mapping in docker-compose.yml (e.g., 8081:80 for frontend, 3001:3000 for backend).
      • If it’s another Docker container, stop it with docker stop <container_id>.
  4. Changes not reflecting:

    • Symptom: You modify your code, but the running application doesn’t change.
    • Cause: You modified a file after the image was built, and the container is running an older version.
    • Fix: You need to rebuild the image and restart the service. Use docker compose build <service_name> (or docker compose build for all) and then docker compose up -d <service_name> (or docker compose up -d for all). If you uncommented the volumes line in docker-compose.yml for development, changes to source code would often reflect without a rebuild (though a service restart might still be needed for Node.js).

Always remember that logs are your best friend when troubleshooting!

Summary: You’re a Full-Stack Containerization Maestro!

Phew! You’ve just completed a significant milestone. Let’s recap what you’ve achieved in this chapter:

  • Understood the “Why”: You know the benefits of containerizing multi-service applications for consistency, isolation, and portability.
  • Designed an Architecture: You broke down a simple web app into frontend and backend services.
  • Built Backend Dockerfile: You created a Dockerfile for a Node.js Express API.
  • Built Frontend Dockerfile: You created a Dockerfile to serve static HTML using Nginx.
  • Orchestrated with Docker Compose: You crafted a docker-compose.yml file to define, build, and run both services together, enabling inter-container communication.
  • Deployed & Verified: You successfully launched your full-stack application and confirmed its functionality.
  • Troubleshooting Skills: You learned to identify and resolve common issues in multi-container setups.

This project-based learning experience has equipped you with practical skills that are directly applicable to real-world development workflows. You’ve moved beyond individual containers and now understand how to manage an entire application stack with Docker!

What’s Next?

In the upcoming chapters, we’ll continue to build on this foundation, exploring more advanced topics that will prepare you for production environments:

  • Chapter 13: Persistent Data with Volumes: Learn how to store application data outside of containers so it persists even when containers are removed.
  • Chapter 14: Environment Variables & Configuration: Discover how to manage application settings dynamically without rebuilding images.
  • Chapter 15: Introduction to Docker Networking: Dive deeper into Docker’s networking models and how they facilitate complex container communication.

Keep up the fantastic work! You’re well on your way to becoming a Docker expert!