Building a Modern Reverse Proxy with Traefik - Part 3: Deploying Your First Application

Welcome to Part 3! In Part 1 we learned about Traefik, and in Part 2 we got it running. Now comes the fun part: deploying actual applications.

By the end of this tutorial, you'll have deployed multiple applications, each with automatic HTTPS, and you'll understand exactly how Traefik's service discovery works.

What We'll Deploy Today

We'll build three progressively complex applications:

  1. Simple static website (HTML/CSS) - Learn the basics
  2. Next.js application - Handle Node.js-specific requirements
  3. Full-stack app (React frontend + Express API + PostgreSQL) - Multiple services

Time estimate: 2-3 hours

Understanding Docker Labels: The Key to Traefik

Before we start deploying, let's understand how Traefik discovers and routes to your applications.

How Traditional Reverse Proxies Work

With Nginx, you'd write a config file:

server {
    server_name myapp.example.com;
    location / {
        proxy_pass http://localhost:3000;
    }
}

Then reload Nginx. If the app moves to a different port, you update the config and reload again.

How Traefik Works

With Traefik, you add labels to your Docker container:

labels:
  - "traefik.http.routers.myapp.rule=Host(`myapp.example.com`)"
  - "traefik.http.services.myapp.loadbalancer.server.port=3000"

Traefik watches Docker and automatically creates routes when it sees these labels. No reload needed.

The Anatomy of Traefik Labels

Let's break down the label structure:

traefik.http.routers.{service-name}.rule=Host(`{domain}`)
│       │    │       │                │
│       │    │       │                └─ Routing rule (Host, Path, etc.)
│       │    │       └─ Unique name for this service (you choose)
│       │    └─ Type of router (http, tcp, udp)
│       └─ Router component
└─ Traefik label prefix

Example labels explained:

# Enable Traefik for this container
- "traefik.enable=true"

# Which network to use (if container is on multiple networks)
- "traefik.docker.network=traefik-network"

# Routing rule (when to send traffic here)
- "traefik.http.routers.blog.rule=Host(`blog.example.com`)"

# Which entrypoint (websecure = HTTPS port 443)
- "traefik.http.routers.blog.entrypoints=websecure"

# Enable TLS and specify certificate resolver
- "traefik.http.routers.blog.tls.certresolver=letsencrypt"

# Tell Traefik which port the app listens on INSIDE the container
- "traefik.http.services.blog.loadbalancer.server.port=3000"

Critical Concept: Internal vs External Ports

This is the #1 source of confusion for Traefik beginners.

Wrong way (won't work):

ports:
  - "8080:3000"
labels:
  - "traefik.http.services.myapp.loadbalancer.server.port=8080"  # ❌

Right way:

# NO ports mapping needed!
labels:
  - "traefik.http.services.myapp.loadbalancer.server.port=3000"  # ✅

Why?

  • Traefik talks to containers via Docker's internal network
  • On that network, containers use their INTERNAL ports
  • You're telling Traefik: "Connect to port 3000 inside the container"
  • Traefik doesn't care about host ports (8080)

The ports: section is only needed when:

  • You want direct access to the container (bypassing Traefik)
  • You're debugging
  • The service uses non-HTTP protocols

Project 1: Static Website

Let's deploy a simple HTML website to learn the basics.

Step 1: Create Project Directory

mkdir -p ~/projects/static-site
cd ~/projects/static-site

Step 2: Create a Simple Website

# Create HTML file
cat > index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My First Traefik Site</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            max-width: 800px;
            margin: 50px auto;
            padding: 20px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
        }
        h1 { font-size: 3em; margin-bottom: 0; }
        p { font-size: 1.2em; opacity: 0.9; }
        .info {
            background: rgba(255,255,255,0.1);
            padding: 20px;
            border-radius: 8px;
            margin-top: 30px;
        }
        code {
            background: rgba(0,0,0,0.2);
            padding: 2px 6px;
            border-radius: 3px;
        }
    </style>
</head>
<body>
    <h1>🚀 It Works!</h1>
    <p>Your first Traefik-powered website is live with automatic HTTPS!</p>

    <div class="info">
        <h3>What's happening here?</h3>
        <ul>
            <li>This HTML is served by an Nginx container</li>
            <li>Traefik detected the container automatically</li>
            <li>Let's Encrypt provided a free SSL certificate</li>
            <li>HTTP requests redirect to HTTPS automatically</li>
            <li>All from a few Docker labels!</li>
        </ul>
        <p><strong>Server time:</strong> <span id="time"></span></p>
    </div>

    <script>
        setInterval(() => {
            document.getElementById('time').textContent = new Date().toLocaleString();
        }, 1000);
    </script>
</body>
</html>
EOF

Step 3: Create Dockerfile

We'll use Nginx to serve our static files:

cat > Dockerfile << 'EOF'
FROM nginx:alpine

# Copy our HTML file
COPY index.html /usr/share/nginx/html/

# Nginx listens on port 80 by default
EXPOSE 80

# Use default nginx.conf
EOF

Step 4: Create docker-compose.yml

cat > docker-compose.yml << 'EOF'
version: '3.8'

services:
  website:
    container_name: static-site
    build: .
    restart: unless-stopped
    networks:
      - traefik-network
    labels:
      # Enable Traefik
      - "traefik.enable=true"

      # Specify the network (required if container is on multiple networks)
      - "traefik.docker.network=traefik-network"

      # Routing rule - CHANGE THIS to your domain!
      - "traefik.http.routers.static-site.rule=Host(`site.lans.cloud`)"

      # Use HTTPS entrypoint
      - "traefik.http.routers.static-site.entrypoints=websecure"

      # Enable automatic SSL
      - "traefik.http.routers.static-site.tls.certresolver=letsencrypt"

      # Tell Traefik the internal port (Nginx uses 80)
      - "traefik.http.services.static-site.loadbalancer.server.port=80"

networks:
  traefik-network:
    external: true
EOF

Important: Change site.lans.cloud to your actual domain (e.g., site.yourdomain.com)

Step 5: Deploy!

docker compose up -d

What happens:

  1. Docker builds the image (first time only)
  2. Starts the container
  3. Container joins traefik-network
  4. Traefik sees the labels
  5. Traefik creates a route for site.yourdomain.com
  6. Traefik requests SSL certificate from Let's Encrypt
  7. Site is live with HTTPS!

All in about 30 seconds.

Step 6: Access Your Site

Open https://site.yourdomain.com in your browser.

You should see your website with:

  • ✅ Valid SSL certificate (green padlock)
  • ✅ Automatic HTTP → HTTPS redirect
  • ✅ Your content displaying correctly

Step 7: Check the Traefik Dashboard

Visit https://traefik.yourdomain.com

You should now see:

  • Routers: static-site@docker with your domain rule
  • Services: static-site@docker pointing to your container
  • HTTP: Green status indicators

Understanding What Just Happened

Let's trace a request through the system:

User types: https://site.yourdomain.com
│
▼
1. DNS resolves to your server IP (203.0.113.45)
│
▼
2. Request hits port 443 (HTTPS)
│
▼
3. Traefik receives the request
│
▼
4. Traefik looks at the Host header: "site.yourdomain.com"
│
▼
5. Traefik matches this to router rule: Host(`site.yourdomain.com`)
│
▼
6. Router points to service: static-site
│
▼
7. Service configuration says: connect to port 80 inside container
│
▼
8. Traefik forwards request to static-site container:80
│
▼
9. Nginx inside container serves index.html
│
▼
10. Traefik returns response to user with SSL encryption

Project 2: Next.js Application

Now let's deploy a Next.js app. This is more complex because Node.js apps require special network configuration.

The Critical Next.js Requirement

Next.js apps must listen on 0.0.0.0 (all interfaces), not localhost or a specific IP.

Why?

In Docker, each container has its own network interface with its own IP. When Next.js binds to localhost, it's only accessible from inside the container itself. Traefik, connecting from the Docker network, can't reach it.

Solution: Set HOSTNAME=0.0.0.0 environment variable.

Step 1: Create Project

mkdir -p ~/projects/nextjs-blog
cd ~/projects/nextjs-blog

Step 2: Create Next.js App Files

Create package.json:

cat > package.json << 'EOF'
{
  "name": "nextjs-traefik-demo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "14.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}
EOF

Create app/page.tsx:

mkdir -p app
cat > app/page.tsx << 'EOF'
export default function Home() {
  return (
    <main style={{
      minHeight: '100vh',
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      color: 'white',
      fontFamily: 'system-ui'
    }}>
      <div style={{ textAlign: 'center', maxWidth: '600px', padding: '20px' }}>
        <h1 style={{ fontSize: '3em', marginBottom: '0.5em' }}>
          ⚡️ Next.js + Traefik
        </h1>
        <p style={{ fontSize: '1.2em', opacity: 0.9 }}>
          This Next.js application was deployed with Traefik automatic service discovery
        </p>
        <div style={{
          background: 'rgba(255,255,255,0.1)',
          padding: '20px',
          borderRadius: '8px',
          marginTop: '30px'
        }}>
          <h3>Features Working:</h3>
          <ul style={{ textAlign: 'left', lineHeight: '2' }}>
            <li>✅ Server-side rendering</li>
            <li>✅ Automatic HTTPS</li>
            <li>✅ Fast Refresh (in dev mode)</li>
            <li>✅ Zero config needed</li>
          </ul>
        </div>
      </div>
    </main>
  );
}
EOF

Create app/layout.tsx:

cat > app/layout.tsx << 'EOF'
export const metadata = {
  title: 'Next.js + Traefik Demo',
  description: 'Next.js app deployed with Traefik',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
EOF

Create next.config.js:

cat > next.config.js << 'EOF'
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Enable standalone output for smaller Docker images
  output: 'standalone',
}

module.exports = nextConfig
EOF

Step 3: Create Optimized Dockerfile

cat > Dockerfile << 'EOF'
FROM node:20-alpine AS base

# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Rebuild source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Production image
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

# CRITICAL: Must listen on 0.0.0.0 for Docker networking
ENV HOSTNAME "0.0.0.0"
ENV PORT 3000

CMD ["node", "server.js"]
EOF

Step 4: Create docker-compose.yml

cat > docker-compose.yml << 'EOF'
version: '3.8'

services:
  nextjs-app:
    container_name: nextjs-blog
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    environment:
      # CRITICAL: Next.js must listen on 0.0.0.0
      - HOSTNAME=0.0.0.0
      - PORT=3000
      - NODE_ENV=production
    networks:
      - traefik-network
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik-network"

      # CHANGE THIS to your domain!
      - "traefik.http.routers.nextjs-blog.rule=Host(`blog.lans.cloud`)"
      - "traefik.http.routers.nextjs-blog.entrypoints=websecure"
      - "traefik.http.routers.nextjs-blog.tls.certresolver=letsencrypt"

      # Next.js default port is 3000
      - "traefik.http.services.nextjs-blog.loadbalancer.server.port=3000"

networks:
  traefik-network:
    external: true
EOF

Change blog.lans.cloud to your domain!

Step 5: Deploy

docker compose up -d --build

This will take a few minutes because Next.js needs to build.

Watch the build:

docker logs -f nextjs-blog

Wait until you see:

ready - started server on 0.0.0.0:3000

Step 6: Access Your Next.js App

Visit https://blog.yourdomain.com

You should see your Next.js app with HTTPS!

Common Next.js Issues and Solutions

Issue: 502 Bad Gateway

Symptom: Traefik shows 502 error

Cause: Next.js not listening on 0.0.0.0

Solution:

environment:
  - HOSTNAME=0.0.0.0  # Must be set!

Issue: "Cannot find module 'next'"

Cause: Dependencies not installed correctly

Solution:

docker compose down
docker compose build --no-cache
docker compose up -d

Issue: Slow Page Loads

Cause: Using dev mode in production

Solution: Make sure NODE_ENV=production is set

Project 3: Full-Stack Application

Now let's deploy a complete application with:

  • React frontend
  • Express API backend
  • PostgreSQL database
  • Internal communication between services

Architecture

Internet → Traefik → Frontend (React)  → Port 3000 → HTTPS
                   ↓
                   Backend (Express) → Port 8000 → HTTPS
                   ↓
                   Database (PostgreSQL) → Port 5432 → Internal only

Only frontend and backend are exposed to the internet. Database is internal.

Step 1: Create Project Structure

mkdir -p ~/projects/fullstack-app/{frontend,backend}
cd ~/projects/fullstack-app

Step 2: Create Backend (Express API)

cd ~/projects/fullstack-app/backend

# Create package.json
cat > package.json << 'EOF'
{
  "name": "backend-api",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.0",
    "cors": "^2.8.5"
  }
}
EOF

# Create index.js
cat > index.js << 'EOF'
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');

const app = express();
const port = 8000;

// Database connection
const pool = new Pool({
  host: process.env.DB_HOST || 'db',
  port: 5432,
  database: process.env.DB_NAME || 'appdb',
  user: process.env.DB_USER || 'appuser',
  password: process.env.DB_PASSWORD || 'changeme',
});

// Middleware
app.use(cors());
app.use(express.json());

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Get items
app.get('/api/items', async (req, res) => {
  try {
    const result = await pool.query('SELECT * FROM items ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Database error' });
  }
});

// Create item
app.post('/api/items', async (req, res) => {
  const { name, description } = req.body;
  try {
    const result = await pool.query(
      'INSERT INTO items (name, description) VALUES ($1, $2) RETURNING *',
      [name, description]
    );
    res.json(result.rows[0]);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Database error' });
  }
});

// Initialize database
async function initDB() {
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS items (
        id SERIAL PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        description TEXT,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      )
    `);
    console.log('Database initialized');
  } catch (err) {
    console.error('Database initialization error:', err);
  }
}

app.listen(port, '0.0.0.0', () => {
  console.log(`API server listening on http://0.0.0.0:${port}`);
  initDB();
});
EOF

# Create Dockerfile
cat > Dockerfile << 'EOF'
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8000
CMD ["npm", "start"]
EOF

Step 3: Create Frontend (React)

cd ~/projects/fullstack-app/frontend

# Create package.json
cat > package.json << 'EOF'
{
  "name": "frontend",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.0.0",
    "vite": "^5.0.0"
  }
}
EOF

# Create vite.config.js
cat > vite.config.js << 'EOF'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    host: '0.0.0.0',
    port: 3000
  },
  preview: {
    host: '0.0.0.0',
    port: 3000
  }
})
EOF

# Create index.html
cat > index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Full Stack Demo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
EOF

# Create src directory
mkdir -p src

# Create src/main.jsx
cat > src/main.jsx << 'EOF'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)
EOF

# Create src/App.jsx
cat > src/App.jsx << 'EOF'
import { useState, useEffect } from 'react';

// Use environment variable or default to relative URL
const API_URL = import.meta.env.VITE_API_URL || 'https://api.lans.cloud';

function App() {
  const [items, setItems] = useState([]);
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');

  useEffect(() => {
    fetchItems();
  }, []);

  const fetchItems = async () => {
    try {
      const res = await fetch(`${API_URL}/api/items`);
      const data = await res.json();
      setItems(data);
    } catch (err) {
      console.error('Error fetching items:', err);
    }
  };

  const addItem = async (e) => {
    e.preventDefault();
    try {
      await fetch(`${API_URL}/api/items`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, description })
      });
      setName('');
      setDescription('');
      fetchItems();
    } catch (err) {
      console.error('Error adding item:', err);
    }
  };

  return (
    <div style={{ maxWidth: '800px', margin: '50px auto', padding: '20px', fontFamily: 'system-ui' }}>
      <h1 style={{ fontSize: '2.5em', marginBottom: '20px' }}>📦 Full Stack Demo</h1>

      <div style={{ background: '#f5f5f5', padding: '20px', borderRadius: '8px', marginBottom: '30px' }}>
        <h2>Add New Item</h2>
        <form onSubmit={addItem}>
          <input
            type="text"
            placeholder="Name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            style={{ width: '100%', padding: '10px', marginBottom: '10px', fontSize: '1em' }}
            required
          />
          <textarea
            placeholder="Description"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            style={{ width: '100%', padding: '10px', marginBottom: '10px', fontSize: '1em', minHeight: '80px' }}
          />
          <button type="submit" style={{ padding: '10px 20px', fontSize: '1em', cursor: 'pointer' }}>
            Add Item
          </button>
        </form>
      </div>

      <div>
        <h2>Items ({items.length})</h2>
        {items.map(item => (
          <div key={item.id} style={{ background: 'white', padding: '15px', marginBottom: '10px', borderRadius: '8px', border: '1px solid #ddd' }}>
            <h3 style={{ margin: '0 0 10px 0' }}>{item.name}</h3>
            <p style={{ margin: 0, color: '#666' }}>{item.description || 'No description'}</p>
            <small style={{ color: '#999' }}>{new Date(item.created_at).toLocaleString()}</small>
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;
EOF

# Create Dockerfile
cat > Dockerfile << 'EOF'
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

# Build argument for API URL
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL

RUN npm run build

# Production server
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF

Important: In src/App.jsx, change https://api.lans.cloud to your actual API domain!

Step 4: Create docker-compose.yml

cd ~/projects/fullstack-app

cat > docker-compose.yml << 'EOF'
version: '3.8'

services:
  # PostgreSQL Database (internal only, not exposed via Traefik)
  db:
    container_name: fullstack-db
    image: postgres:15-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - internal

  # Express API Backend
  backend:
    container_name: fullstack-api
    build: ./backend
    restart: unless-stopped
    environment:
      - DB_HOST=db
      - DB_NAME=appdb
      - DB_USER=appuser
      - DB_PASSWORD=changeme
    depends_on:
      - db
    networks:
      - traefik-network
      - internal
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik-network"

      # CHANGE THIS to your domain!
      - "traefik.http.routers.fullstack-api.rule=Host(`api.lans.cloud`)"
      - "traefik.http.routers.fullstack-api.entrypoints=websecure"
      - "traefik.http.routers.fullstack-api.tls.certresolver=letsencrypt"
      - "traefik.http.services.fullstack-api.loadbalancer.server.port=8000"

      # CORS middleware
      - "traefik.http.middlewares.api-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolalloworiginlist=https://app.lans.cloud"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization"
      - "traefik.http.middlewares.api-cors.headers.accesscontrolallowcredentials=true"
      - "traefik.http.routers.fullstack-api.middlewares=api-cors"

  # React Frontend
  frontend:
    container_name: fullstack-frontend
    build:
      context: ./frontend
      args:
        # CHANGE THIS to match your API domain!
        VITE_API_URL: https://api.lans.cloud
    restart: unless-stopped
    networks:
      - traefik-network
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik-network"

      # CHANGE THIS to your domain!
      - "traefik.http.routers.fullstack-frontend.rule=Host(`app.lans.cloud`)"
      - "traefik.http.routers.fullstack-frontend.entrypoints=websecure"
      - "traefik.http.routers.fullstack-frontend.tls.certresolver=letsencrypt"
      - "traefik.http.services.fullstack-frontend.loadbalancer.server.port=80"

networks:
  traefik-network:
    external: true
  internal:
    driver: bridge

volumes:
  db-data:
EOF

Change these domains:

  • api.lans.cloud → your API domain
  • app.lans.cloud → your frontend domain
  • Also update in CORS and build args!

Step 5: Deploy the Full Stack

docker compose up -d --build

This will take several minutes. Three containers are building.

Monitor progress:

docker compose logs -f

Step 6: Test the Application

  1. Visit frontend: https://app.yourdomain.com
  2. Add an item using the form
  3. Verify it appears in the list
  4. Check API directly: https://api.yourdomain.com/health

You now have a complete full-stack application running with:

  • ✅ React frontend with HTTPS
  • ✅ Express API with HTTPS
  • ✅ PostgreSQL database (internal)
  • ✅ CORS configured
  • ✅ All services talking to each other

Understanding the Network Architecture

                    traefik-network
                           │
        ┌──────────────────┼──────────────────┐
        │                  │                  │
    Frontend           Backend           Traefik
        │                  │
        │                  │
        │          internal network
        │                  │
        │                  ├─────── DB
        │                  │

Key points:

  • Frontend and Backend are on traefik-network (so Traefik can reach them)
  • Backend and DB are on internal network (so they can talk)
  • Frontend is NOT on internal (doesn't need direct DB access)
  • DB is NOT on traefik-network (not exposed publicly)

Troubleshooting Common Issues

Frontend Can't Reach API

Symptoms: Network errors in browser console

Causes:

  1. Wrong API URL in frontend code
  2. CORS not configured
  3. API not running

Debug:

# Check API is accessible
curl https://api.yourdomain.com/health

# Check CORS headers
curl -I https://api.yourdomain.com/api/items

Fix CORS: The labels in docker-compose.yml handle this, but make sure the accesscontrolalloworiginlist matches your frontend domain.

Backend Can't Reach Database

Symptoms: "Database connection error" in logs

Causes:

  1. DB not on same network as backend
  2. Wrong DB credentials
  3. DB not ready when backend starts

Debug:

# Check backend logs
docker logs fullstack-api

# Check if DB is running
docker exec fullstack-db psql -U appuser -d appdb -c "\dt"

Fix: Add depends_on and health check:

backend:
  depends_on:
    db:
      condition: service_healthy

Port Conflicts

Symptoms: "bind: address already in use"

Cause: Another service using the same port

Debug:

sudo lsof -i :80
sudo lsof -i :443

Fix: Make sure only Traefik uses these ports (no nginx, apache, etc.)

Best Practices for Production

1. Use Environment Variables

Don't hardcode secrets:

environment:
  - DB_PASSWORD=${DB_PASSWORD}  # Read from .env file

Create .env file:

DB_PASSWORD=your-secure-password-here

Add to .gitignore:

.env

2. Health Checks

Add health checks to ensure services are ready:

services:
  backend:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

3. Resource Limits

Prevent containers from consuming all resources:

services:
  backend:
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M

4. Logging

Centralize logs:

services:
  backend:
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

5. Database Backups

Create a backup script:

#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
docker exec fullstack-db pg_dump -U appuser appdb > backup_$DATE.sql
# Upload to S3 or backup service

What's Next?

In Part 4: Advanced Features, we'll cover:

  • Rate limiting to prevent abuse
  • Custom authentication middleware
  • WebSocket support (for real-time features)
  • Advanced CORS configuration
  • Serving multiple apps from one container
  • Custom error pages
  • Monitoring and metrics

Summary

You've learned how to:

  • ✅ Deploy static websites with Traefik
  • ✅ Deploy Next.js applications (with critical HOSTNAME config)
  • ✅ Deploy full-stack applications with multiple services
  • ✅ Configure Docker networks for security
  • ✅ Set up CORS for API access
  • ✅ Understand Traefik labels in depth
  • ✅ Debug common deployment issues

Key Takeaways:

  1. Traefik labels define routing, not config files
  2. Use INTERNAL container ports in labels
  3. Node.js apps must listen on 0.0.0.0
  4. Separate public and private networks for security
  5. CORS must match your actual domains

Continue to Part 4 to level up your Traefik skills with advanced features!