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 composefor 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:
- Install Node.js and its dependencies for the backend.
- Install all frontend build tools (like Node.js again for npm/yarn, webpack, etc.).
- Ensure compatible versions of everything.
- 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 upcommand.
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:
- 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.
- 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.htmlloads 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 usenpm startinside the container."dependencies": { "express": "^4.18.2" }: Declaresexpressas 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:80orlocalhost: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 specifiedport.
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 thenode:20-alpineimage.alpineis a very small Linux distribution, making our image lightweight.20is a recent LTS (Long Term Support) version of Node.js as of late 2025.AS builderis 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 ./: Copiespackage.jsonandpackage-lock.json(if you have one) from your host machine into the/appdirectory in the container. We do this before copying the rest of the code so that Docker can cache thenpm installstep. If onlyapp.jschanges, Docker won’t need to reinstall dependencies.RUN npm install: Executesnpm installinside the container to download and install all the Node.js dependencies defined inpackage.json.COPY . .: Copies all remaining files from your current directory (./backend) on the host to the/appdirectory 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 bydocker composeordocker run.CMD ["npm", "start"]: Specifies the command to run when the container starts. This executes ourstartscript defined inpackage.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 adocker composenetwork, they can refer to each other by their service names as hostnames. So,backendhere resolves to the IP address of ourbackendservice container. We then specify the internal port (3000) that the Node.js app is listening on.- The
try...catchblock 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 ourindex.htmlto 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-alpineis 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 customnginx.confinto the Nginx configuration directory. Nginx automatically loads.conffiles from this directory.COPY . /usr/share/nginx/html: Copies all files from ourfrontenddirectory (which includesindex.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(or3.9if 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 namedbackend. This name is important because the frontend will use it for communication.build: ./backend: Instead of pulling a pre-existing image, this tellsdocker composeto build an image using theDockerfilefound in the./backenddirectory.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 viahttp://localhost:3000.# volumes: - ./backend:/app: This line is commented out but is a fantastic tip for development! If uncommented, it would mount your localbackenddirectory into the container’s/appdirectory. This means any changes you make toapp.json your host machine would immediately reflect inside the running container (often requiring a restart of the Node.js process within the container, which tools likenodemoncan 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 namedfrontend.build: ./frontend: Builds the frontend image using theDockerfilein the./frontenddirectory.ports: - "80:80" - "8080:80": Maps host port 80 to container port 80 (standard HTTP). We also add8080:80as an alternative, in case port 80 is already in use on your host machine by another application. You can then access the frontend viahttp://localhostorhttp://localhost:8080.depends_on: - backend: This is a crucial instruction. It tellsdocker composethat thefrontendservice depends on thebackendservice. This meansdocker composewill try to start thebackendcontainer before starting thefrontendcontainer. Important:depends_ononly 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 buildreads yourdocker-compose.ymlfile.- For each service (
backendandfrontend) that has abuildinstruction, it navigates to the specified directory (./backendor./frontend). - It then executes the
Dockerfilein 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 indocker-compose.yml.-d: Runs the containers in “detached” mode (in the background), so your terminal prompt remains free.docker composewill create a default network for your project, allowing thebackendandfrontendcontainers 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://localhostorhttp://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 bydocker compose.docker compose down --rmi all: Doesdownand also removes all images that were built for this project (ourbackendandfrontendimages).
Mini-Challenge: Enhance the Backend!
You’ve built a solid foundation. Now, let’s make it a tiny bit more interactive!
Challenge:
- Modify the
backend/app.jsfile to include a new endpoint:/api/greeting. - This new endpoint should accept a
namequery parameter (e.g.,/api/greeting?name=Alice). - It should return a JSON response like
{ "greeting": "Hello, Alice from Dockerized Backend!" }. If nonameis provided, it should default to “Guest”. - Update the
frontend/index.htmlto 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/greetingendpoint with the provided name and display the response.
Hint:
- For the backend, you can access query parameters using
req.query.namein Express. - After modifying
app.jsandindex.html, remember that you’ll need to rebuild the images and restart the services for changes to take effect.- For
backend/app.jschanges, you’ll needdocker compose build backendthendocker compose up -d backend. - For
frontend/index.htmlchanges, you’ll needdocker compose build frontendthendocker compose up -d frontend. - Or, simply
docker compose build && docker compose up -dto rebuild and restart everything.
- For
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:
- Replace the content of
backend/app.jswith the updated code. - Replace the content of
frontend/index.htmlwith the updated code. - In your terminal (from the
my-fullstack-approot directory):docker compose build && docker compose up -d - Refresh your browser at
http://localhost(orhttp://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:
“Container exited with code 1” / App not starting:
- Symptom: Your
docker compose psshows a service withExitedstatus. - 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 missingnode_modulesor a syntax error inapp.js.
- Symptom: Your
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/apiinindex.html. Ensurebackendis the correct service name indocker-compose.ymland3000is the port your Node.js app is actually listening on. You can also trydocker exec -it <frontend_container_id> ping backendto verify network connectivity. - Cause 2 (CORS): The backend isn’t sending the correct
Access-Control-Allow-Originheader. - Fix 2: Verify your
app.jsincludes the CORS middleware. In a development environment,res.header('Access-Control-Allow-Origin', '*')is common. In production, be more specific.
- Symptom: Your browser’s developer console shows network errors (e.g.,
Port Conflicts (
Bind for 0.0.0.0:80 failed: port is already allocated):- Symptom:
docker compose upfails 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:80for frontend,3001:3000for backend). - If it’s another Docker container, stop it with
docker stop <container_id>.
- Symptom:
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>(ordocker compose buildfor all) and thendocker compose up -d <service_name>(ordocker compose up -dfor all). If you uncommented thevolumesline indocker-compose.ymlfor 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
Dockerfilefor a Node.js Express API. - Built Frontend Dockerfile: You created a
Dockerfileto serve static HTML using Nginx. - Orchestrated with Docker Compose: You crafted a
docker-compose.ymlfile 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!