Skip to content
Build a Full-Stack App with Docker Multi-Stage Builds (Step by Step)

Build a Full-Stack App with Docker Multi-Stage Builds (Step by Step)

DodaTech Updated Jun 20, 2026 9 min read

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

Step 1: Project Structure

mkdir fullstack-docker
cd fullstack-docker
mkdir frontend backend database
fullstack-docker/
├── frontend/
│   ├── Dockerfile
│   ├── package.json
│   ├── public/
│   └── src/
├── backend/
│   ├── Dockerfile
│   ├── main.go
│   ├── go.mod
│   └── handlers/
├── database/
│   └── init.sql
├── docker-compose.yml
└── .env

Step 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

How do I debug a multi-stage build step by step?
Add --progress=plain to docker build to see all steps. Or build specific stages: docker build --target builder -t myapp-builder . then run a shell in that stage to inspect files.
Can I use Docker Compose in production?
Yes, with caution. Docker Compose is fine for single-host deployments. For multi-host or orchestrated environments, use Docker Swarm or Kubernetes. The compose file can be translated to Kubernetes manifests with kompose.
How do I set environment-specific configuration?
Use a .env file in the project root. Docker Compose reads it automatically. The backend service uses ${DB_PASSWORD:-secret} — the default secret is used if the env var is not set. For secrets, use Docker secrets or a vault.

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