Build a Full-Stack App with Docker Multi-Stage Builds (Step by Step)
Build a full-stack application using Docker multi-stage builds with a React frontend, Go API backend, PostgreSQL database, Docker Compose orchestration, health checks, and persistent volumes.
What You’ll Build
You’ll build a full-stack task manager with a React frontend, a Go API backend, and PostgreSQL storage — all containerized with Docker multi-stage builds. The final images are optimized, small, and production-ready. This approach is used by DodaTech to containerize DodaZIP’s web interface and Durga Antivirus Pro’s management console.
Why Multi-Stage Builds Matter
Multi-stage builds reduce final image size by separating build dependencies from runtime. A React app built with Node.js ends up as a static file served by NGINX in a ~20 MB image. A Go binary compiled in one stage runs in a scratch image ~5 MB. This means faster deploys, lower bandwidth, and smaller attack surface. Understanding multi-stage builds is essential for production containerization.
Prerequisites
- Docker and Docker Compose installed
- Go basics (for the API server)
- React fundamentals (for the frontend)
- Basic PostgreSQL knowledge
Step 1: Project Structure
mkdir fullstack-docker
cd fullstack-docker
mkdir frontend backend databasefullstack-docker/
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── public/
│ └── src/
├── backend/
│ ├── Dockerfile
│ ├── main.go
│ ├── go.mod
│ └── handlers/
├── database/
│ └── init.sql
├── docker-compose.yml
└── .envStep 2: Go API Backend
// backend/go.mod
module github.com/dodatech/taskapi
go 1.21
require github.com/lib/pq v1.10.9// backend/main.go
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
_ "github.com/lib/pq"
)
var db *sql.DB
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
func main() {
var err error
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
getEnv("DB_HOST", "localhost"),
getEnv("DB_PORT", "5432"),
getEnv("DB_USER", "app"),
getEnv("DB_PASSWORD", "secret"),
getEnv("DB_NAME", "tasks"),
)
db, err = sql.Open("postgres", connStr)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
log.Fatal("Cannot connect to DB:", err)
}
log.Println("Connected to PostgreSQL")
// Initialize table
db.Exec(`CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false
)`)
http.HandleFunc("/api/health", healthHandler)
http.HandleFunc("/api/tasks", tasksHandler)
http.HandleFunc("/api/tasks/", taskHandler)
port := getEnv("PORT", "8080")
log.Printf("API server on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}
func tasksHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case "GET":
rows, err := db.Query("SELECT id, title, completed FROM tasks ORDER BY id")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
rows.Scan(&t.ID, &t.Title, &t.Completed)
tasks = append(tasks, t)
}
json.NewEncoder(w).Encode(tasks)
case "POST":
var t Task
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, err.Error(), 400)
return
}
err := db.QueryRow(
"INSERT INTO tasks (title, completed) VALUES ($1, $2) RETURNING id",
t.Title, t.Completed,
).Scan(&t.ID)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(201)
json.NewEncoder(w).Encode(t)
}
}
func taskHandler(w http.ResponseWriter, r *http.Request) {
// Task-specific routes (PUT, DELETE) would go here
}Expected output: A Go API that connects to PostgreSQL, creates a tasks table, exposes GET/POST at /api/tasks, and a health check at /api/health.
Step 3: Multi-Stage Dockerfile for Go Backend
# backend/Dockerfile
# ---- Build Stage ----
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./main.go
# ---- Runtime Stage ----
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /server .
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
CMD ["./server"]The first stage installs Go dependencies and compiles a static binary. The second stage copies only the binary into a tiny Alpine image. The HEALTHCHECK pings the health endpoint every 30 seconds.
Step 4: React Frontend
// frontend/src/App.js
import React, { useState, useEffect } from 'react';
const API = process.env.REACT_APP_API_URL || '/api';
function App() {
const [tasks, setTasks] = useState([]);
const [title, setTitle] = useState('');
useEffect(() => {
fetch(`${API}/tasks`)
.then(res => res.json())
.then(setTasks)
.catch(err => console.error(err));
}, [API]);
const addTask = async () => {
if (!title.trim()) return;
const res = await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const task = await res.json();
setTasks([...tasks, task]);
setTitle('');
};
return (
<div>
<h1>Task Manager</h1>
<div>
<input value={title} onChange={e => setTitle(e.target.value)} />
<button onClick={addTask}>Add</button>
</div>
<ul>
{tasks.map(task => (
<li key={task.id}>{task.title}</li>
))}
</ul>
</div>
);
}
export default App;# frontend/Dockerfile
# ---- Build Stage ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# ---- Runtime Stage ----
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]Step 5: Database Init Script
-- database/init.sql
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO tasks (title) VALUES ('Learn Docker'), ('Build full-stack app'), ('Deploy to production');Step 6: Docker Compose
# docker-compose.yml
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD:-secret}
POSTGRES_DB: tasks
volumes:
- pgdata:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app -d tasks']
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
environment:
DB_HOST: db
DB_PORT: '5432'
DB_USER: app
DB_PASSWORD: ${DB_PASSWORD:-secret}
DB_NAME: tasks
PORT: '8080'
ports:
- '8080:8080'
depends_on:
db:
condition: service_healthy
restart: unless-stopped
frontend:
build: ./frontend
ports:
- '80:80'
depends_on:
- backend
restart: unless-stopped
volumes:
pgdata:Expected output: Running docker-compose up --build starts PostgreSQL first (waiting for health check), then the Go backend (connected to DB), then the React frontend (served by NGINX). Visit http://localhost to see the task manager.
Step 7: NGINX Reverse Proxy Config
# frontend/nginx.conf
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}This proxies /api/ requests to the Go backend, avoiding CORS issues in production.
Architecture
graph TD
subgraph "Docker Compose"
A[NGINX
React Static Files] -->|/api/ proxy| B[Go API Server
Port 8080]
B -->|TCP 5432| C[PostgreSQL
Port 5432]
end
subgraph "Multi-Stage Builds"
D[Node:20-alpine] -->|npm run build| E[Build Artifacts]
E -->|COPY| A
F[Golang:1.21-alpine] -->|go build| G[Static Binary]
G -->|COPY| B
end
H[User Browser] -->|Port 80| A
style A fill:#009639,color:white
style B fill:#00ADD8,color:white
style C fill:#336791,color:white
Common Errors
1. “exec: ‘wget’: executable file not found” in alpine
Alpine images are minimal. wget is not included. Either install it in the Dockerfile (apk add wget) or use CMD curl --fail http://localhost:8080/api/health || exit 1 with curl (apk add curl). The Go Dockerfile uses wget — alpine doesn’t have it by default.
2. PostgreSQL container restarts in a loop
The init script has a syntax error. Check docker logs <container-id> for PostgreSQL errors. Common issues: missing semicolons, quoting mismatches, or typos in SQL. The init.sql runs only on first startup.
3. React app shows blank page with CORS errors in console
The NGINX proxy forwards /api/ to the backend. In development, use React’s proxy: add "proxy": "http://localhost:8080" to package.json. In production, the NGINX config handles this. Never add Access-Control-Allow-Origin: * to the Go API in production.
4. Backend cannot connect to PostgreSQL (connection refused)
The backend starts before PostgreSQL is ready. The depends_on with condition: service_healthy waits for the health check. If healthcheck is misconfigured, the backend starts too early. Add a retry loop in Go: for { err := db.Ping(); if err == nil { break }; time.Sleep(1 * time.Second) }.
5. Multi-stage build copies wrong files
The builder Dockerfile copies all of backend/ into the build context. If there are large local files (.git, node_modules), they bloat the build stage. Use .dockerignore to exclude:
# .dockerignore (per service)
.git
*.md
node_modules/Practice Questions
1. What is the benefit of the Alpine-based runtime stage over using the full golang image for production? Alpine is ~5 MB vs golang:alpine at ~300 MB. The full image includes compilers, package managers, and libraries unnecessary at runtime. Multi-stage builds copy only the compiled binary, reducing attack surface and deploy time.
2. Why does the frontend need an NGINX reverse proxy instead of directly calling the Go API?
To avoid CORS. The browser enforces same-origin policy. If frontend is on port 80 and API is on port 8080, cross-origin requests are blocked. NGINX proxies /api/* to port 8080, making all requests same-origin. In development, React’s dev server has a built-in proxy.
3. What happens to the database data when containers are restarted?
The pgdata named volume persists data across restarts. Even if the container is deleted (docker-compose down), the volume remains. To reset, run docker-compose down -v to remove volumes as well. Never delete volumes in production without backup.
4. Challenge: Add a Redis cache layer Add a Redis service to docker-compose. Cache GET /api/tasks responses in Redis with a 60-second TTL. Invalidate the cache on POST/PUT/DELETE. This reduces database load and improves response times for read-heavy workloads.
5. Challenge: Separate development and production compose files
Create docker-compose.override.yml for development that mounts source code as volumes (hot-reload) and uses dev servers. The base docker-compose.yml uses multi-stage production builds. Run with docker-compose up for dev, docker-compose -f docker-compose.yml up for production.
FAQ
Next Steps
- Add Redis caching to the Go backend
- Explore Kubernetes deployment with the same Docker images
- Learn about PostgreSQL advanced features (indexes, migrations)
- Check the CI/CD tutorial for automated testing and building
- Try the Docker Monitoring Stack project to monitor these containers
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro