Containerize a Three-Tier Web App
Orchestrate a React, Node.js, and MySQL app using Docker Compose.
Introduction
30 Second Summary
Every developer has heard "it works on my machine." Containers solve this by packaging your application and all its dependencies into a portable unit that runs identically everywhere.
In this project, you will containerize a three-tier task management app using Docker and Docker Compose. You'll build production-optimized images, orchestrate multiple services, scan for vulnerabilities, and automate the whole pipeline with GitHub Actions.
What You'll Build
A task management application with a React frontend served through Nginx, a Node.js API handling business logic, and a MySQL database for persistence. Each tier runs in its own isolated container.
By the end of this project, you'll have:
- Multi-stage Dockerfiles that separate build tooling from runtime, dramatically reducing image sizes.
- A compose.yaml orchestrating three services with custom networks, health checks, and volume persistence.
- A GitHub Actions CI pipeline that builds, scans images with Trivy, and pushes to Docker Hub.
- Secret Mission: Publish images to GitHub Container Registry as a second push target alongside Docker Hub.
Are there any prerequisites?
You should have basic Docker experience (pulling images and running containers) and familiarity with React and Node.js concepts. You'll also need Docker Desktop installed and a Docker Hub account.
Set Up the Project Structure
Before you can containerize anything, you need an application to put inside the containers. This step creates the source code for a task management app with three tiers: a React frontend, a Node.js API, and a MySQL database.
You will write all the source code now so that the remaining steps can focus entirely on Docker. The app itself is straightforward. The interesting work begins when you containerize and orchestrate it.
In this step, get ready to:
- Create the monorepo directory structure with separate folders for each tier.
- Write the React frontend source files.
- Write the Node.js API server and database initialization script.
Create the directory structure and frontend source
A three-tier app needs a clear folder structure. Each tier lives in its own directory so that Docker can build them independently.
- Open your terminal by pressing Ctrl+Alt+T.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and all subdirectories by running these commands:
mkdir -p three-tier-app/frontend/src three-tier-app/api/src three-tier-app/db three-tier-app/.github/workflows
cd three-tier-app
- Confirm the directories exist by running:
ls
You should see api, db, and frontend listed in the output.
Why this folder structure?
Each folder becomes a separate Docker build context. Docker builds one image per folder, keeping each tier isolated from the others.
- Open the three-tier-app folder in your code editor.
Now create the frontend source files. The frontend is a React app built with Vite that displays tasks and lets users add or delete them.
- Create a new file called package.json inside the frontend/ folder with this content:
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.4.1",
"vite": "^6.3.0"
}
}
- Create index.html in the frontend/ folder with this content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Task Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
- Create vite.config.js in the frontend/ folder with this content:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
What do these config files do?
The package.json declares the project's dependencies (React, Vite) and defines build scripts. Docker will run npm install and npm run build inside a container using this file.
The index.html is the single HTML page that loads the React app. The vite.config.js tells Vite to use the React plugin when building.
- Create main.jsx inside frontend/src/ with this content:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
- Create App.jsx inside frontend/src/ with this content:
import { useState, useEffect } from 'react'
function App() {
const [tasks, setTasks] = useState([])
const [newTask, setNewTask] = useState('')
useEffect(() => {
fetchTasks()
}, [])
async function fetchTasks() {
const res = await fetch('/api/tasks')
const data = await res.json()
setTasks(data)
}
async function addTask(e) {
e.preventDefault()
if (!newTask.trim()) return
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTask })
})
setNewTask('')
fetchTasks()
}
async function deleteTask(id) {
await fetch(`/api/tasks/${id}`, { method: 'DELETE' })
fetchTasks()
}
return (
<div className="container">
<h1>Task Manager</h1>
<form onSubmit={addTask}>
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span>{task.title}</span>
<button onClick={() => deleteTask(task.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
export default App
- Create index.css inside frontend/src/ with this content:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
padding: 2rem;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 1rem;
color: #333;
}
form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button {
padding: 0.5rem 1rem;
background: #0070f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #0051a8;
}
ul {
list-style: none;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
li button {
background: #ff4444;
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
}
li button:hover {
background: #cc0000;
}
What does the frontend source do?
The main.jsx file is the entry point that mounts the React app into the HTML page.
The App.jsx component fetches tasks from /api/tasks, renders them in a list, and provides forms to add or delete tasks. It talks to the API tier through relative URLs.
The index.css provides clean styling for the task list, form, and buttons.
- Verify all frontend files exist by running this from the three-tier-app root:
ls frontend/ frontend/src/
You should see index.html, package.json, and vite.config.js in the first listing, and App.jsx, index.css, and main.jsx in the second.
Missing a file?
Double check you saved each file in the correct folder. Files in frontend/src/ are one level deeper than files in frontend/.
help me figure out why my frontend files are missing
Write the Node.js API source
The API tier handles the business logic. It connects to MySQL, exposes CRUD endpoints for tasks, and runs on port 3000 inside its container.
- Create package.json inside the api/ folder with this content:
{
"name": "api",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.21.2",
"mysql2": "^3.12.0"
}
}
Why only two dependencies?
The API needs Express for HTTP routing and mysql2 for database connections. Keeping dependencies minimal makes the Docker image smaller and reduces the vulnerability surface area.
- Create index.js inside api/src/ with this content:
const express = require('express')
const mysql = require('mysql2/promise')
const app = express()
app.use(express.json())
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'taskuser',
password: process.env.DB_PASSWORD || 'taskpass',
database: process.env.DB_NAME || 'taskdb',
waitForConnections: true,
connectionLimit: 10,
})
app.get('/api/tasks', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC')
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.post('/api/tasks', async (req, res) => {
try {
const { title } = req.body
const [result] = await pool.query('INSERT INTO tasks (title) VALUES (?)', [title])
res.status(201).json({ id: result.insertId, title })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.delete('/api/tasks/:id', async (req, res) => {
try {
await pool.query('DELETE FROM tasks WHERE id = ?', [req.params.id])
res.status(204).end()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' })
})
const PORT = process.env.PORT || 3000
app.listen(PORT, '0.0.0.0', () => {
console.log(`API server running on port ${PORT}`)
})
What does the API server do?
The pool object creates a reusable connection pool to MySQL. It reads database credentials from environment variables, with hardcoded fallbacks for local development.
The three route handlers cover the full CRUD lifecycle for tasks: GET /api/tasks lists them, POST /api/tasks creates one, and DELETE /api/tasks/:id removes one.
The /api/health endpoint returns a simple status check. Docker Compose will use this later to verify the API is ready.
- Verify the API files exist by running:
ls api/ api/src/
You should see package.json and src in the first listing, and index.js in the second.
Don't see index.js in api/src/?
Make sure you created the file inside api/src/ and not directly in api/. The start script in package.json expects the path src/index.js.
help me fix my API file structure
Create the database script and environment variables
The database tier needs an initialization script that creates the tasks table. You also need a root .env file that centralizes all configuration for the stack.
- Create init.sql inside the db/ folder with this content:
USE taskdb;
CREATE TABLE IF NOT EXISTS tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO tasks (title) VALUES
('Learn Docker'),
('Build multi-stage images'),
('Set up Docker Compose');
What does init.sql do?
When the MySQL container starts for the first time, it automatically runs any SQL files placed in its /docker-entrypoint-initdb.d/ directory. This script creates the tasks table and inserts three sample rows so the app has data immediately.
- Create a .env file in the project root (three-tier-app/) with this content:
# MySQL
MYSQL_ROOT_PASSWORD=rootsecret
MYSQL_DATABASE=taskdb
MYSQL_USER=taskuser
MYSQL_PASSWORD=taskpass
# API
DB_HOST=db
DB_PORT=3306
DB_USER=taskuser
DB_PASSWORD=taskpass
DB_NAME=taskdb
PORT=3000
Why one .env file for everything?
Docker Compose can inject environment variables from a single file into multiple containers. The MySQL variables (MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, etc.) configure the database container on first boot.
The API variables (DB_HOST, DB_USER, etc.) tell the Node.js server how to connect. Notice DB_HOST=db points to the service name, not localhost. Docker's internal DNS resolves service names to container IPs automatically.
- Confirm the database and environment files exist by running:
ls db/ .env
You should see init.sql and .env in the output.
Don't see .env in the listing?
Files starting with a dot are hidden by default. If ls .env shows an error, the file may be in the wrong directory or named incorrectly. Make sure you created it directly in three-tier-app/ (not inside frontend/ or api/).
help me find my .env file
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is every file you should have at this point. Compare yours against these.
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.4.1",
"vite": "^6.3.0"
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Task Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
import { useState, useEffect } from 'react'
function App() {
const [tasks, setTasks] = useState([])
const [newTask, setNewTask] = useState('')
useEffect(() => {
fetchTasks()
}, [])
async function fetchTasks() {
const res = await fetch('/api/tasks')
const data = await res.json()
setTasks(data)
}
async function addTask(e) {
e.preventDefault()
if (!newTask.trim()) return
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTask })
})
setNewTask('')
fetchTasks()
}
async function deleteTask(id) {
await fetch(`/api/tasks/${id}`, { method: 'DELETE' })
fetchTasks()
}
return (
<div className="container">
<h1>Task Manager</h1>
<form onSubmit={addTask}>
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span>{task.title}</span>
<button onClick={() => deleteTask(task.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
export default App
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
padding: 2rem;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 1rem;
color: #333;
}
form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button {
padding: 0.5rem 1rem;
background: #0070f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #0051a8;
}
ul {
list-style: none;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
li button {
background: #ff4444;
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
}
li button:hover {
background: #cc0000;
}
{
"name": "api",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.21.2",
"mysql2": "^3.12.0"
}
}
const express = require('express')
const mysql = require('mysql2/promise')
const app = express()
app.use(express.json())
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'taskuser',
password: process.env.DB_PASSWORD || 'taskpass',
database: process.env.DB_NAME || 'taskdb',
waitForConnections: true,
connectionLimit: 10,
})
app.get('/api/tasks', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC')
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.post('/api/tasks', async (req, res) => {
try {
const { title } = req.body
const [result] = await pool.query('INSERT INTO tasks (title) VALUES (?)', [title])
res.status(201).json({ id: result.insertId, title })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.delete('/api/tasks/:id', async (req, res) => {
try {
await pool.query('DELETE FROM tasks WHERE id = ?', [req.params.id])
res.status(204).end()
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' })
})
const PORT = process.env.PORT || 3000
app.listen(PORT, '0.0.0.0', () => {
console.log(`API server running on port ${PORT}`)
})
USE taskdb;
CREATE TABLE IF NOT EXISTS tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO tasks (title) VALUES
('Learn Docker'),
('Build multi-stage images'),
('Set up Docker Compose');
# MySQL
MYSQL_ROOT_PASSWORD=rootsecret
MYSQL_DATABASE=taskdb
MYSQL_USER=taskuser
MYSQL_PASSWORD=taskpass
# API
DB_HOST=db
DB_PORT=3306
DB_USER=taskuser
DB_PASSWORD=taskpass
DB_NAME=taskdb
PORT=3000
Your application source code is ready. Next up, you will write Dockerfiles that package this code into optimized container images.
Write the Frontend Dockerfile
Your application source code is written, but right now it only exists as files on disk. To run the React frontend inside a container, Docker needs a set of instructions describing how to turn that source code into a deployable image.
In this step, you will write a Dockerfile that uses a multi-stage build to compile your React app and serve it with Nginx. The first stage installs dependencies and builds static assets. The second stage copies those assets into a lightweight Nginx image that is ready for production.
In this step, get ready to:
- Create a .dockerignore to keep the build context lean.
- Write a multi-stage Dockerfile that builds React and serves it with Nginx.
- Configure Nginx to handle client-side routing and proxy API requests.
Create the .dockerignore file
When you run docker build, Docker sends every file in the build directory to the Docker daemon as the "build context." A .dockerignore file tells Docker which files to exclude from that context, keeping your image lean and preventing sensitive files from leaking in.
- Create a new file called .dockerignore inside your frontend/ directory with the following content:
node_modules
dist
.git
.gitignore
*.md
.env
.vscode
What does this file do?
Each line is a pattern that Docker will skip when building the context.
- node_modules is excluded because the Dockerfile installs fresh dependencies inside the container.
- dist is excluded because the container builds its own production output.
- .env is excluded so secrets never get baked into an image layer.
- Save frontend/.dockerignore.
You won't see a visible change yet. The Dockerfile you write next will use this file automatically during the build.
Write the multi-stage Dockerfile
A multi-stage build uses multiple FROM statements in a single Dockerfile. Each FROM starts a new stage, and you can copy artifacts from one stage to the next.
The final image only contains what the last stage needs to run. All the build tooling (Node.js, npm, source code) gets left behind, keeping the production image small.
- Create a file called Dockerfile inside your frontend/ directory with the following content:
# Stage 1: Build the React application
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
What does this Dockerfile do?
Stage 1 (build):
- Starts from node:22-alpine, a lightweight Node.js image.
- Copies package.json first and runs npm install to cache the dependency layer separately from source code changes.
- Copies the rest of the source and runs npm run build to produce static assets in dist/.
Stage 2 (production):
- Starts fresh from nginx:alpine, which has zero Node.js tooling.
- Copies only the built assets from Stage 1 into Nginx's serve directory.
- Copies your custom nginx.conf (which you will create next).
Why multi-stage builds?
A naive single-stage Dockerfile that keeps Node.js in the final image produces images around 400-500 MB. The multi-stage approach discards everything except the compiled HTML, CSS, and JS files plus the tiny Nginx runtime.
The result is a production image under 50 MB. Smaller images pull faster, start faster, and have a smaller attack surface.
- Save frontend/Dockerfile.
You won't build this image yet. The next section adds the Nginx config file it references.
✔️ Awesome, I've got everything!
Great. Make sure you have saved frontend/Dockerfile before moving on.
ⓧ I'd like to double check the full code
# Stage 1: Build the React application
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Write the Nginx configuration
The Dockerfile copies an nginx.conf file into the image, but that file does not exist yet. This configuration tells Nginx how to serve the React app and where to forward API requests.
- Create a file called nginx.conf inside your frontend/ directory with the following content:
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
What does this configuration do?
The location / block handles all frontend routes:
- try_files $uri $uri/ /index.html tries to serve the exact file first. If no file matches, it falls back to index.html so React's client-side router can handle the URL.
The location /api block handles API requests:
- Any request starting with /api is forwarded to http://api:3000. The hostname api is the service name that Docker Compose will resolve via its internal DNS.
- Save frontend/nginx.conf.
- Confirm all three new files exist by running:
ls frontend/.dockerignore frontend/Dockerfile frontend/nginx.conf
You should see all three file paths printed without errors.
Seeing "No such file or directory"?
- Make sure you created each file inside the frontend/ folder, not in the project root.
- Check that .dockerignore starts with a dot. Files starting with a dot are hidden by default in file browsers.
- Verify the Dockerfile has no file extension. It should be Dockerfile, not Dockerfile.txt.
Still stuck? Help me figure out why my frontend Docker files are missing.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is frontend/.dockerignore:
node_modules
dist
.git
.gitignore
*.md
.env
.vscode
Here is frontend/Dockerfile:
# Stage 1: Build the React application
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Here is frontend/nginx.conf:
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Your frontend is ready to be containerized. Next up, you will write the API's Dockerfile using a similar multi-stage approach with a focus on security.
Write the API Dockerfile
Your frontend Dockerfile is ready. Now the API needs the same treatment.
The API Dockerfile follows a similar multi-stage pattern, but with a key difference. Because the API runs on Node.js at runtime (not just at build time), both stages use node:22-alpine. The optimization comes from separating dependency installation into its own stage and adding security hardening with a non-root user.
In this step, get ready to:
- Write a .dockerignore file for the API service.
- Write a multi-stage Dockerfile that installs production-only dependencies and runs as a non-root user.
- Build both images and compare sizes against a naive single-stage build.
Create the API .dockerignore
Just like the frontend, the API needs a .dockerignore to keep unnecessary files out of the build context.
- Create a file called .dockerignore inside the api/ directory and add the following content:
node_modules
.git
.gitignore
*.md
.env
.vscode
What does this file do?
The .dockerignore tells Docker which files to skip when copying the build context into the image. Without it, node_modules from your host would get sent to Docker (even though the Dockerfile installs its own).
Excluding .env prevents secrets from being baked into the image layer history.
- Save api/.dockerignore.
Write the multi-stage Dockerfile
The API Dockerfile uses two stages. The first stage installs only production dependencies. The second stage copies those dependencies into a clean image and adds a non-root user for security.
- Create a file called Dockerfile inside the api/ directory and add the following content:
# Stage 1: Install production dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
# Stage 2: Production image
FROM node:22-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]
What does this Dockerfile do?
- Stage 1 (deps): Copies only package.json and runs npm install --omit=dev. The --omit=dev flag skips devDependencies, keeping the install lean.
- Stage 2 (production): Creates a non-root user (appuser), copies node_modules from Stage 1, adds the source code, then switches to the non-root user before running the app.
- The USER appuser line is a security best practice. If the container is compromised, the attacker does not have root access.
Why no npm ci here?
The npm ci command requires a package-lock.json file. This project skips the lockfile for simplicity, so npm install is the correct choice. In a production codebase, you would commit a lockfile and use npm ci for reproducible installs.
- Save api/Dockerfile.
File not recognized as a Dockerfile?
Make sure the filename is exactly Dockerfile with a capital D and no file extension. Some editors add .txt by default.
help me fix my API Dockerfile
✔️ Awesome, I've got everything!
Great. Double check you have saved both api/.dockerignore and api/Dockerfile.
ⓧ I'd like to double check the full code
Here is the complete api/.dockerignore:
node_modules
.git
.gitignore
*.md
.env
.vscode
Here is the complete api/Dockerfile:
# Stage 1: Install production dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
# Stage 2: Production image
FROM node:22-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]
Build both images and compare sizes
With both Dockerfiles written, it is time to build the images and see how multi-stage builds affect image size.
- Make sure you are in the project root directory (the folder that contains frontend/, api/, and db/).
- Build the frontend image by running:
docker build -t three-tier-frontend ./frontend
- Build the API image by running:
docker build -t three-tier-api ./api
- Both builds should complete without errors. You will see each stage execute in sequence in the terminal output.
Build failing with COPY errors?
Make sure you are running the docker build commands from the project root, not from inside the api/ or frontend/ directories. The build context path (./frontend or ./api) is relative to where you run the command.
help me debug my Docker build errors
Now build a naive single-stage image to see what you are saving. This uses the full node:22 base (not Alpine) and bundles everything in one stage.
- Build a naive comparison image by running:
docker build -t three-tier-api-naive -f - ./api <<EOF
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "src/index.js"]
EOF
What is this command doing?
The -f - flag tells Docker to read the Dockerfile from stdin instead of a file. The heredoc (<<EOF) feeds a temporary Dockerfile that uses the full node:22 image and installs all dependencies (including dev). This is the naive approach you are improving on.
- Compare all three image sizes by running:
docker images | grep three-tier
- You should see three images listed with their sizes. The three-tier-api image (multi-stage, Alpine) will be dramatically smaller than three-tier-api-naive (single-stage, full Node). Expect a reduction of 80% or more.
Why such a big size difference?
The full node:22 image is built on Debian and includes compilers, system libraries, and tools you do not need at runtime. The node:22-alpine image uses Alpine Linux, which is around 50 MB compared to over 200 MB for the full image.
The multi-stage approach makes this even better. Stage 1 does the heavy lifting (installing packages), but only the final node_modules folder carries over to Stage 2. Build tools and caches stay behind.
- Clean up the naive image since you only needed it for comparison:
docker rmi three-tier-api-naive
Both images are built and optimized. Next up, you will orchestrate all three services (frontend, API, and database) together with Docker Compose.
Configure Docker Compose
You have three working Docker images, but running them individually with separate docker build and docker run commands is tedious. You also need the containers to talk to each other over a network, share environment variables, and start in the right order.
Docker Compose solves this by letting you define all your services, networks, and volumes in a single YAML file. One command brings everything up together.
In this step, get ready to:
- Define three services with build contexts, ports, and restart policies.
- Create custom networks that isolate the frontend from the database.
- Add health checks and persistent storage for MySQL.
Define the three services
A Compose file defines each container as a service. Each service specifies where to find its Dockerfile, which ports to expose, and how to handle restarts.
- Create a file called compose.yaml in your project root (the same level as your .env file) and paste the following service definitions:
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
restart: unless-stopped
api:
build:
context: ./api
dockerfile: Dockerfile
env_file:
- .env
restart: unless-stopped
db:
image: mysql:8.4
env_file:
- .env
restart: unless-stopped
What does this code do?
- frontend builds from the ./frontend directory and maps container port 80 to host port 8080.
- api builds from ./api and loads environment variables from the root .env file (database credentials, port number).
- db uses the official mysql:8.4 image directly instead of building a custom Dockerfile. It also loads .env for the MySQL root password and database name.
- restart: unless-stopped tells Docker to automatically restart each container if it crashes, unless you explicitly stop it.
- Save compose.yaml.
- Confirm the file exists by running:
ls compose.yaml
You should see compose.yaml listed in the output.
File not showing up?
Make sure you created the file in the project root directory, not inside frontend/ or api/. Run ls and check that you can see both .env and compose.yaml side by side.
Help me find where my compose.yaml file ended up
Add network isolation between tiers
By default, Docker Compose puts all services on a single shared network. That means the frontend container could connect directly to the database, bypassing the API entirely.
Custom networks enforce the principle of least privilege. The frontend only needs to reach the API, and the API only needs to reach the database.
- In compose.yaml, add a networks key to each service and define the top-level networks. Update your file so it looks like this:
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
networks:
- frontend-net
restart: unless-stopped
api:
build:
context: ./api
dockerfile: Dockerfile
env_file:
- .env
networks:
- frontend-net
- backend-net
restart: unless-stopped
db:
image: mysql:8.4
env_file:
- .env
networks:
- backend-net
restart: unless-stopped
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
Why two networks instead of one?
frontend-net connects the frontend to the API. backend-net connects the API to the database. The frontend is only on frontend-net, so it physically cannot reach the database.
The API sits on both networks because it needs to receive requests from the frontend AND query the database. This is how production Docker deployments enforce tier isolation.
- Save compose.yaml.
- Validate your Compose file syntax by running:
docker compose config --quiet
If there is no output and no error, your YAML syntax is valid.
Seeing a YAML parsing error?
YAML is sensitive to indentation. Make sure you use spaces (not tabs) and that each nested key is indented exactly 2 spaces deeper than its parent. Check that networks: at the top level has no leading spaces.
Help me fix this YAML error in my compose.yaml
Add health checks, startup order, and persistent storage
The API depends on MySQL being fully ready to accept connections, not just started. A health check lets Docker verify the database is actually responsive before starting the API.
- Update compose.yaml to add the depends_on conditions, the MySQL health check, and the named volume. Your complete file should look like this:
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
networks:
- frontend-net
depends_on:
api:
condition: service_started
restart: unless-stopped
api:
build:
context: ./api
dockerfile: Dockerfile
env_file:
- .env
networks:
- frontend-net
- backend-net
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mysql:8.4
env_file:
- .env
volumes:
- mysql-data:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
volumes:
mysql-data:
What does this code do?
- depends_on with condition: service_healthy tells Docker to wait until the db service passes its health check before starting the API. The frontend uses condition: service_started because it only needs the API container to exist, not be fully ready.
- The healthcheck runs mysqladmin ping every 10 seconds to verify MySQL is accepting connections. The $$MYSQL_ROOT_PASSWORD uses a double dollar sign so Compose passes a literal $ to the container shell, which then expands the env var at runtime.
- mysql-data:/var/lib/mysql mounts a named volume so database data survives container restarts. Without it, you would lose all tasks every time the container stops.
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql mounts your initialization script into the directory MySQL automatically runs on first startup.
Why does the health check need credentials?
In MySQL 8.4, mysqladmin ping without credentials fails with an access denied error. The health check must include -u root -p$$MYSQL_ROOT_PASSWORD to authenticate. The double dollar sign prevents Compose from trying to interpolate the variable during file parsing.
- Save compose.yaml.
- Validate the final file by running:
docker compose config --quiet
No output means the file is valid. Your full stack definition is ready to launch.
Getting a variable interpolation warning?
If you see a warning about MYSQL_ROOT_PASSWORD, check that you used $$MYSQL_ROOT_PASSWORD (two dollar signs) in the healthcheck test. A single $ causes Compose to try to resolve the variable at parse time, which fails because the variable only exists inside the container.
Help me fix the variable interpolation in my health check
✔️ Awesome, I've got everything!
Great. Double check you have saved compose.yaml.
ⓧ I'd like to double check the full code
Here is the complete compose.yaml file. Compare it against yours character for character.
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
networks:
- frontend-net
depends_on:
api:
condition: service_started
restart: unless-stopped
api:
build:
context: ./api
dockerfile: Dockerfile
env_file:
- .env
networks:
- frontend-net
- backend-net
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mysql:8.4
env_file:
- .env
volumes:
- mysql-data:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
volumes:
mysql-data:
Your entire stack is defined in a single file. Next up, you will bring it all to life with one command and verify the application works end-to-end.
Build and Run the Full Stack
You have all three tiers defined: a React frontend, a Node.js API, and a MySQL database. Each has its own Dockerfile and the compose.yaml ties them together with networks, volumes, and health checks.
Now it is time to bring everything up with a single command and prove the stack works end-to-end. You will also verify that the network isolation you configured actually prevents the frontend from reaching the database directly.
In this step, get ready to:
- Build all images and start the stack with Docker Compose.
- Verify tasks can be created, deleted, and persisted across container restarts.
- Confirm network isolation between the frontend and database containers.
Build and start the full stack
Docker Compose reads your compose.yaml and orchestrates the build and startup of all three services in the correct order.
- Build all images and start the stack in detached mode by running this command:
docker compose up --build -d
What does this command do?
The --build flag tells Compose to build images from your Dockerfiles before starting containers.
The -d flag runs everything in detached mode so the containers run in the background and you get your terminal back.
Compose starts services respecting depends_on conditions: the db starts first, the API waits for the database health check to pass, then the frontend starts.
- Wait about 30-45 seconds for the MySQL health check to pass and all services to become healthy.
- Check that all three containers are running by running:
docker compose ps
You should see three services listed (frontend, api, db) with a Status showing they are running. The db service should show a health status of "healthy".
Containers not all running?
- If the db container shows "starting" or "unhealthy", wait another 30 seconds. The start_period: 30s in the health check gives MySQL time to initialize.
- If a container exited, run docker compose logs <service-name> to see the error output for that specific service.
- If port 8080 is already in use, stop whatever is using it or change the port mapping in compose.yaml to a different host port.
help me troubleshoot why my Docker Compose containers are not starting
Verify the application end-to-end
With all containers running, you can interact with the task manager through your browser. The frontend serves the React app, which talks to the API through the Nginx proxy, which in turn reads and writes to MySQL.
- Open your browser and navigate to http://localhost:8080.
- You should see the Task Manager heading with three pre-loaded tasks: "Learn Docker", "Build multi-stage images", and "Set up Docker Compose".
- Type a new task name in the input field (for example, Run the full stack) and click Add.
- Confirm the new task appears at the top of the list.
- Click Delete next to one of the pre-loaded tasks and confirm it disappears.
What is happening behind the scenes?
When you add a task, the browser sends a POST request to /api/tasks. Nginx in the frontend container proxies this to the API container on port 3000.
The API inserts a row into the tasks table in MySQL. The frontend then fetches the updated list and re-renders.
Now confirm that data persists across container restarts. This proves the named volume is working correctly.
- Restart all containers by running:
docker compose restart
- Wait about 30 seconds for the database health check to pass again.
- Refresh http://localhost:8080 in your browser.
- Confirm that the task you added earlier is still there and the one you deleted is still gone.
Why does data survive a restart?
The mysql-data named volume in your compose.yaml stores the database files outside the container filesystem. When containers restart, the volume remains intact and MySQL reads its existing data.
Tasks disappeared after restart?
- Make sure you used docker compose restart and not docker compose down. The down command removes containers but does not remove volumes by default. However, if you used docker compose down --volumes, that deletes the volume and all data is lost.
- If the page shows an error after restart, the database may still be starting up. Wait 30 seconds and refresh again.
help me figure out why my task data is not persisting across Docker Compose restarts
Inspect network isolation and service health
Your compose.yaml defines two networks: frontend-net and backend-net. The frontend container only connects to frontend-net, which means it physically cannot reach the database on backend-net.
- List all Docker networks to find your project's network names by running:
docker network ls
You should see two networks with names ending in _frontend-net and _backend-net. The prefix is your project directory name.
- Inspect the backend network to see which containers are connected by running:
docker network inspect $(docker network ls --filter name=backend-net -q)
What should I see?
In the JSON output, look for the Containers section. You should see only the api and db containers listed here.
The frontend container is absent from this network. This proves the frontend has no route to the database. Even if the frontend container were compromised, it could not directly access MySQL.
- Now check the service logs to confirm everything started cleanly by running:
docker compose logs --tail 20
- Look for API server running on port 3000 in the api logs.
- Look for ready for connections in the db logs.
Why two separate networks?
This follows the principle of least privilege. The API sits on both networks because it needs to accept requests from the frontend AND query the database.
The frontend only needs to reach the API. Placing it on a separate network from the database reduces the blast radius if the frontend is ever compromised. This is how production Docker deployments enforce tier isolation.
Seeing an empty Containers section?
- Make sure all containers are still running with docker compose ps. If any have exited, run docker compose up -d to restart them.
- Double-check that your compose.yaml has the networks key under each service. If services do not specify networks, they all join a single default network instead.
help me understand why docker network inspect shows no containers on my backend-net
Your three-tier stack is running, verified, and isolated. Next up, you will scan these images for security vulnerabilities before they go anywhere near production.
Scan Images with Trivy
Your three-tier stack is running and verified. But shipping container images without checking them for known vulnerabilities is like deploying code without running tests.
In this step, you will install Trivy, an open-source vulnerability scanner, and use it to scan your Docker images for security issues. You will also learn how to make Trivy fail with a non-zero exit code, which is the key behavior that makes it useful in CI/CD pipelines.
In this step, get ready to:
- Install Trivy v0.70.0 on your system.
- Scan your frontend image for HIGH and CRITICAL vulnerabilities.
- Scan your API image with the --exit-code 1 flag to demonstrate CI/CD gating.
Install Trivy
Trivy is a container image scanner that checks every package inside your image against a database of known vulnerabilities (CVEs). It reports which packages are affected, the severity level, and whether a fix is available.
- Install Trivy v0.70.0 using the official install script by running this command:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.70.0
What does this command do?
- curl -sfL downloads the official Trivy install script silently (no progress bar, fail on errors, follow redirects).
- The script is piped to sudo sh -s -- which runs it with root permissions.
- -b /usr/local/bin tells the script to place the binary in /usr/local/bin so it is available on your PATH.
- v0.70.0 pins the exact version to install.
- Verify the installation by running:
trivy --version
- You should see Version: 0.70.0 in the output.
Command not found?
If your terminal does not recognize trivy, the install script may not have placed the binary on your PATH. Check that /usr/local/bin is included in your PATH by running echo $PATH.
help me troubleshoot the Trivy installation on my Ubuntu system
Scan the frontend image
Before you can scan, you need to know the exact name of your Docker images. Docker Compose names images based on your project folder.
- List your local images by running:
docker images
Look for two images with names ending in -frontend and -api. The prefix is your project folder name. For example, if your folder is called three-tier-app, the images are three-tier-app-frontend and three-tier-app-api.
- Scan the frontend image for HIGH and CRITICAL vulnerabilities by running:
trivy image --severity HIGH,CRITICAL [[FRONTEND_IMAGE="your-frontend-image-name"]]
What does this command do?
- trivy image tells Trivy to scan a container image.
- --severity HIGH,CRITICAL filters the results to only show vulnerabilities rated HIGH or CRITICAL. Lower-severity issues (LOW, MEDIUM) are hidden.
- The first time you run Trivy, it downloads a vulnerability database. This may take 30-60 seconds.
Trivy outputs a table with columns for each vulnerability it finds. Here is how to read the output:
- Library shows the affected package name (e.g. libcrypto3).
- Vulnerability shows the CVE ID (e.g. CVE-2024-12797).
- Severity shows HIGH or CRITICAL.
- Installed Version shows the version currently in your image.
- Fixed Version shows which version patches the vulnerability. If this column is empty, no fix is available yet.
- Title gives a short description of the vulnerability.
Why does my image have vulnerabilities?
The frontend image uses nginx:alpine as its base. Even minimal Alpine images include system libraries like OpenSSL that may have known CVEs. This is normal. The important thing is knowing about them so you can decide whether to accept the risk, upgrade, or switch base images.
Scan takes a very long time?
The first scan downloads the vulnerability database (around 40 MB). If your connection is slow, this can take a few minutes. Subsequent scans use the cached database and run much faster.
help me troubleshoot slow Trivy image scans
Scan the API image with exit-code gating
By default, Trivy exits with code 0 even when it finds vulnerabilities. This means a CI/CD pipeline would not fail. The --exit-code 1 flag changes this behavior so Trivy returns a non-zero exit code when vulnerabilities matching your severity filter are found.
- Scan the API image with the exit-code flag by running:
trivy image --severity HIGH,CRITICAL --exit-code 1 [[API_IMAGE="your-api-image-name"]]
What does --exit-code 1 do?
When Trivy finds any HIGH or CRITICAL vulnerability, it returns exit code 1 instead of 0. In a CI/CD pipeline, a non-zero exit code causes the pipeline step to fail. This acts as a quality gate that blocks vulnerable images from being pushed to a registry.
Without this flag, Trivy reports vulnerabilities but the pipeline continues to the next step regardless. The scan becomes informational only.
- Check the exit code from the last command by running:
echo $?
- If any HIGH or CRITICAL vulnerabilities were found, you should see 1 printed. If no vulnerabilities matched, you see 0.
Exit codes in CI/CD context
In the next step, you will use this exact --exit-code 1 flag inside a GitHub Actions workflow. If the scan finds vulnerabilities, the workflow stops before pushing images to Docker Hub. This prevents vulnerable images from reaching production.
Your images are scanned and you understand how Trivy gates vulnerable builds. Next up, you will automate this entire build-scan-push workflow with GitHub Actions so it runs on every code change.
Create the GitHub Actions CI Pipeline
You have a working Docker Compose stack and Trivy scanning images locally. The next challenge is automating this entire process so that every code push triggers a build, scan, and push without manual intervention.
In this step, you will create a GitHub Actions workflow that builds both container images, scans them with Trivy, and pushes them to Docker Hub. This is a complete CI/CD pipeline that gates deployments on vulnerability checks.
In this step, get ready to:
- Create the workflow YAML with checkout, Buildx, and Docker Hub login steps.
- Add build and vulnerability scan steps that gate the pipeline.
- Push the code to GitHub and verify the workflow runs successfully.
Create the workflow file with checkout, Buildx, and login
GitHub Actions workflows live in a special directory at the root of your repository. The runner picks up any YAML file inside .github/workflows/ and executes it based on the triggers you define.
- Create the workflow directory by running these commands:
mkdir -p .github/workflows
- Create the ci.yml file inside .github/workflows/ and add the following content:
name: CI - Build, Scan, Push
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
jobs:
build-scan-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
What does this workflow do so far?
- on: push/pull_request triggers the workflow whenever code is pushed to main or a pull request targets main.
- env: DOCKERHUB_USERNAME pulls your Docker Hub username from a repository variable, keeping it out of the code.
- actions/checkout@v4 clones the repository into the runner so subsequent steps can access your Dockerfiles.
- docker/setup-buildx-action@v4 creates a Buildx builder instance that supports advanced features like layer caching.
- docker/login-action@v4 authenticates to Docker Hub, but only on push events (skipped on PRs to avoid leaking credentials).
- Save .github/workflows/ci.yml.
- Confirm the file exists by running:
ls .github/workflows/
You should see ci.yml listed in the output.
Don't see ci.yml?
Make sure you created the directory with the -p flag, which creates parent directories. Double check that the file path is exactly .github/workflows/ci.yml (not github/ without the leading dot).
help me fix my workflow file path
Add the build and scan steps
The workflow needs to build both images locally (without pushing), then scan them for vulnerabilities. The load: true option loads the built image into the runner's Docker daemon so Trivy can inspect it.
- In .github/workflows/ci.yml, add the following steps below the Docker Hub login step (after the password: ${{ secrets.DOCKERHUB_TOKEN }} line):
- name: Build frontend image
uses: docker/build-push-action@v7
with:
context: ./frontend
load: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build API image
uses: docker/build-push-action@v7
with:
context: ./api
load: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.70.0
- name: Scan frontend image
run: trivy image --severity HIGH,CRITICAL --exit-code 1 ${{ env.DOCKERHUB_USERNAME }}/three-tier-frontend:latest
- name: Scan API image
run: trivy image --severity HIGH,CRITICAL --exit-code 1 ${{ env.DOCKERHUB_USERNAME }}/three-tier-api:latest
What do these steps accomplish?
The two build steps use docker/build-push-action@v7 with load: true instead of push: true. This builds the image and loads it into the local Docker daemon without pushing it anywhere yet.
cache-from: type=gha and cache-to: type=gha,mode=max leverage GitHub Actions' built-in cache backend for Docker layers. Subsequent runs reuse cached layers, dramatically speeding up builds.
The Trivy scan steps use --exit-code 1 to fail the entire workflow if HIGH or CRITICAL vulnerabilities are found. This is the gate that prevents vulnerable images from being pushed.
- Save .github/workflows/ci.yml.
You won't see a change yet because the workflow only runs on GitHub. The next chunk adds the push steps and triggers the pipeline.
YAML indentation errors?
YAML is indentation-sensitive. Each step must be indented with exactly 6 spaces (2 for steps, 2 for the list item -, 2 for the content). If your editor uses tabs, convert them to spaces.
help me fix YAML indentation in my GitHub Actions workflow
Add push steps, push to GitHub, and verify the pipeline
The final steps push the scanned images to Docker Hub. They only run on push events to main (not on pull requests), ensuring images are pushed only after merging.
- In .github/workflows/ci.yml, add the following steps below the API scan step:
- name: Push frontend image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v7
with:
context: ./frontend
push: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Push API image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v7
with:
context: ./api
push: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Why rebuild instead of pushing the loaded image?
The push steps rebuild with push: true instead of directly pushing the previously loaded image. This is because docker/build-push-action uses the GHA cache layers from the earlier build step, so the rebuild is near-instant. The action handles tagging and pushing in one operation.
The if: github.event_name != 'pull_request' condition skips pushing on PRs. Pull requests still build and scan (catching issues early), but only merged code gets published to Docker Hub.
- Save .github/workflows/ci.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
name: CI - Build, Scan, Push
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
jobs:
build-scan-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build frontend image
uses: docker/build-push-action@v7
with:
context: ./frontend
load: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build API image
uses: docker/build-push-action@v7
with:
context: ./api
load: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.70.0
- name: Scan frontend image
run: trivy image --severity HIGH,CRITICAL --exit-code 1 ${{ env.DOCKERHUB_USERNAME }}/three-tier-frontend:latest
- name: Scan API image
run: trivy image --severity HIGH,CRITICAL --exit-code 1 ${{ env.DOCKERHUB_USERNAME }}/three-tier-api:latest
- name: Push frontend image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v7
with:
context: ./frontend
push: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Push API image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v7
with:
context: ./api
push: true
tags: ${{ env.DOCKERHUB_USERNAME }}/three-tier-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Now you need to push this code to GitHub. The workflow requires two pieces of configuration in your repository: a secret for your Docker Hub token and a variable for your Docker Hub username.
- Create a new repository on GitHub. Name it three-tier-docker (or any name you prefer) and leave it public.
- On your repository page, click Settings.
- In the Security section of the sidebar, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, type DOCKERHUB_TOKEN.
- In the Secret field, paste your Docker Hub access token.
- Click Add secret.
Where do I get a Docker Hub access token?
Log in to Docker Hub, click your profile icon, then select Account settings. Navigate to Personal access tokens and generate a new token with Read and Write permissions.
- Back on the Secrets and variables page, click the Variables tab.
- Click New repository variable.
- In the Name field, enter DOCKERHUB_USERNAME.
- In the Value field, enter your Docker Hub username.
- Click Add variable.
Why a variable for the username but a secret for the token?
Secrets are encrypted and masked in workflow logs. Your Docker Hub token is sensitive, so it belongs in secrets. Your username is not sensitive (it appears in public image tags anyway), so a plain configuration variable works.
With the repository configured, push your project code to trigger the workflow.
- Initialize a git repository and push to GitHub by running these commands:
git init
git add .
git commit -m "Initial commit: three-tier Docker app with CI"
git branch -M main
git remote add origin [[REPO_URL="https://github.com/your-username/three-tier-docker.git"]]
git push -u origin main
- Open your repository on GitHub.
- Click the Actions tab.
- You should see a workflow run in progress or completed. Click on it to view the job details.
A green checkmark next to the build-scan-push job means your pipeline built both images, scanned them with Trivy, and pushed them to Docker Hub successfully.
Workflow failed with a red X?
Click on the failed job to see which step failed. Common causes:
- Login failed: verify that DOCKERHUB_TOKEN is set correctly in your repository secrets and that the token has Read and Write permissions.
- Trivy scan failed: this means vulnerabilities were found. Check the scan output for details. For now, you can remove --exit-code 1 temporarily to let the pipeline pass while you address vulnerabilities.
- Variable not found: make sure you added DOCKERHUB_USERNAME as a repository variable (not a secret) under the Variables tab.
help me debug my failing GitHub Actions workflow
- Verify your images arrived on Docker Hub by visiting your Docker Hub profile. You should see three-tier-frontend and three-tier-api repositories listed.
Your entire build-scan-push workflow is now automated. Every push to main builds fresh images, gates them through a vulnerability scan, and publishes them to Docker Hub. Next up, check out the Secret Mission to add a second container registry to your pipeline.
Secret mission
Add GitHub Container Registry as a Second Push Target
Your CI pipeline pushes to Docker Hub. But what if Docker Hub goes down, or your organization wants images on GitHub's own registry too? In this secret mission, you'll configure your workflow to push to both Docker Hub and GitHub Container Registry (ghcr.io) simultaneously, with automatic tags generated from git events.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers. Docker Hub's free tier allows unlimited public repositories, so there are no ongoing costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers (frontend, api, db).
- Docker images (three-tier-frontend, three-tier-api, mysql:8.4).
- Docker networks (frontend-net, backend-net).
- Docker volume (mysql-data).
- Trivy binary installed at /usr/local/bin/trivy.
- Local project directory on your Desktop.
- GitHub repository with the CI workflow.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the stack.
- The stack uses minimal resources while idle. Leave it running for continued development.
- Trivy remains installed at /usr/local/bin/trivy for future image scanning.
- Your GitHub repository keeps the CI workflow ready to run on your next push.
Pause - I'll come back to this later
Stop running containers to free up memory without removing anything. You can resume later without rebuilding.
- Stop all containers by running:
docker compose stop
- When you're ready to resume, start them again by running:
docker compose start
Your images, volumes, and data all remain intact.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes containers, networks, volumes, and built images.
- Remove all Docker Compose resources (containers, networks, volumes, and images) by running:
docker compose down --volumes --rmi all
What does this command do?
The --volumes flag removes named volumes like mysql-data, which are not removed by default. The --rmi all flag removes all images used by the services.
- Remove the Trivy binary by running:
sudo rm /usr/local/bin/trivy
- Delete the local project directory by running:
cd ~/Desktop
rm -rf three-tier-docker
- To delete your GitHub repository, go to your repository page on GitHub.
- Click Settings.
- Scroll down to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Nice Work!
Nice Work!
You just containerized a production-grade three-tier application from scratch. The entire stack is portable, reproducible, and ready for deployment anywhere with a single command.
You've learned how to:
- Build multi-stage Dockerfiles that separate build tooling from runtime, dramatically reducing image sizes.
- Orchestrate three services with Docker Compose using custom networks for tier isolation, health checks, and startup dependencies.
- Automate a complete build-scan-push pipeline with GitHub Actions and Trivy vulnerability scanning to gate deployments.
- Published images to multiple container registries in a single CI workflow by adding GitHub Container Registry as a second push target.
Ready to quiz yourself?