Tech Trends

Docker in Practice: Deploying Your First Web Application from Scratch

Alex Chen··12 min min read
Share:
AdvertisementAdSense

When I first heard about Docker, the explanations made it sound simple: "It packages your app with everything it needs to run." But when I actually tried to dockerize my first application, I spent three days fighting with network configurations, volume permissions, and mysterious "connection refused" errors.

What I needed wasn't a conceptual explanation—I needed someone to walk me through the exact steps, explain why each decision was made, and show me what production-ready actually looks like.

This tutorial is that guide. By the end, you'll have a multi-container application running with Docker Compose, understand the "why" behind each configuration choice, and know how to deploy it to a production server.

What We're Building

We'll dockerize a full-stack application consisting of:

  • A Node.js/Express API backend
  • A React frontend
  • A PostgreSQL database
  • Redis for caching

This stack represents 80% of real-world web applications. Once you understand how to dockerize this, you can adapt the approach to virtually any stack.

Prerequisites

  • Docker and Docker Compose installed on your machine
  • Basic familiarity with command line
  • Node.js installed (for local development, not required for Docker)

Verify Docker is working:

docker --version
docker compose version

Step 1: Understanding Docker Fundamentals

Before we write any configuration, let's clarify three concepts that confused me when I started:

Images vs Containers

An image is a blueprint—a read-only template with your application code, runtime, libraries, and dependencies. Think of it like a class in programming.

A container is a running instance of an image. It's isolated, has its own filesystem, and runs as a separate process. Think of it like an object instantiated from a class.

One image can spawn many containers. This is how Docker enables scaling—you run multiple containers from the same image behind a load balancer.

The Dockerfile

A Dockerfile is a text file with instructions for building an image. Each instruction creates a "layer," and Docker caches layers that haven't changed. This is why rebuilding an image is fast after the first time—unchanged layers are reused.

Docker Compose

While Docker handles single containers, Docker Compose orchestrates multi-container applications. You define all services, networks, and volumes in a docker-compose.yml file, then start everything with a single command.

This is what we'll use for our full-stack application.

Step 2: Creating the Application Structure

Let's create our project structure:

docker-demo/
├── backend/
│   ├── src/
│   │   └── index.js
│   ├── package.json
│   └── Dockerfile
├── frontend/
│   ├── src/
│   │   └── App.jsx
│   ├── package.json
│   └── Dockerfile
├── nginx/
│   └── nginx.conf
├── docker-compose.yml
└── .env

The Backend (Node.js/Express)

Create backend/package.json:

{
  "name": "docker-demo-backend",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.0",
    "pg": "^8.11.0",
    "redis": "^4.6.0",
    "cors": "^2.8.5"
  },
  "scripts": {
    "start": "node src/index.js",
    "dev": "node src/index.js"
  }
}

Create backend/src/index.js:

const express = require('express');
const { Pool } = require('pg');
const redis = require('redis');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.json());

// Database connection
const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: process.env.DB_PORT || 5432,
  database: process.env.DB_NAME || 'myapp',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || 'password',
});

// Redis connection
const redisClient = redis.createClient({
  url: `redis://${process.env.REDIS_HOST || 'localhost'}:6379`
});
redisClient.connect().catch(console.error);

// Health check endpoint
app.get('/health', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    const redisPing = await redisClient.ping();
    res.json({ status: 'healthy', database: 'connected', cache: redisPing });
  } catch (error) {
    res.status(500).json({ status: 'unhealthy', error: error.message });
  }
});

// API endpoint with caching
app.get('/api/data', async (req, res) => {
  try {
    // Try cache first
    const cached = await redisClient.get('api_data');
    if (cached) {
      return res.json({ source: 'cache', data: JSON.parse(cached) });
    }

    // Fall back to database
    const result = await pool.query('SELECT NOW() as time, version() as version');
    const data = result.rows[0];
    
    // Cache for 60 seconds
    await redisClient.setEx('api_data', 60, JSON.stringify(data));
    
    res.json({ source: 'database', data });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running on port ${PORT}`);
});

The Backend Dockerfile

Create backend/Dockerfile:

# Stage 1: Dependencies
FROM node:20-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app

# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Copy dependencies from stage 1
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules

# Copy application code
COPY --chown=nodejs:nodejs src ./src
COPY --chown=nodejs:nodejs package.json ./

# Switch to non-root user
USER nodejs

# Expose port
EXPOSE 3001

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3001/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"

# Start application
CMD ["npm", "start"]

Key decisions explained:

  1. Multi-stage build: We use two stages—one to install dependencies, one for production. The final image doesn't include npm cache or build tools, making it smaller and more secure.

  2. Alpine Linux: The node:20-alpine image is ~40MB vs ~900MB for the full Debian-based image. In production, smaller images mean faster deployments and less attack surface.

  3. Non-root user: Running as root inside a container is a security risk. We create a nodejs user and run the app under it.

  4. Health check: Docker can monitor if your application is actually healthy, not just running. If the health check fails, Docker can restart the container automatically.

Step 3: The Frontend (React)

Create frontend/package.json:

{
  "name": "docker-demo-frontend",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "vite": "^5.0.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0"
  }
}

Create frontend/src/App.jsx:

import { useState, useEffect } from 'react';

function App() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  return (
    <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
      <h1>Docker Demo App</h1>
      {loading && <p>Loading...</p>}
      {error && <p style={{ color: 'red' }}>Error: {error}</p>}
      {data && (
        <div>
          <p><strong>Source:</strong> {data.source}</p>
          <pre>{JSON.stringify(data.data, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

export default App;

Create frontend/src/main.jsx:

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>
);

Create frontend/index.html:

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

The Frontend Dockerfile

Create frontend/Dockerfile:

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production with Nginx
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

This is a common pattern: build the React app in a Node container, then serve the static files with Nginx. Nginx is much more efficient at serving static files than Node.js.

Step 4: Nginx Configuration

Create nginx/nginx.conf:

server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # Serve static files
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy API requests to backend
    location /api/ {
        proxy_pass http://backend:3001/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript text/xml;
}

This Nginx configuration does two critical things:

  1. Serves the React app's static files
  2. Proxies /api/* requests to the backend service

Step 5: Docker Compose Orchestration

Create docker-compose.yml:

version: '3.8'

services:
  # PostgreSQL Database
  db:
    image: postgres:16-alpine
    container_name: demo-db
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DB_PASSWORD:-secretpassword}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  # Redis Cache
  redis:
    image: redis:7-alpine
    container_name: demo-redis
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - app-network

  # Backend API
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: demo-backend
    environment:
      NODE_ENV: production
      PORT: 3001
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: myapp
      DB_USER: postgres
      DB_PASSWORD: ${DB_PASSWORD:-secretpassword}
      REDIS_HOST: redis
    ports:
      - "3001:3001"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-network
    restart: unless-stopped

  # Frontend (Nginx)
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: demo-frontend
    ports:
      - "80:80"
    depends_on:
      - backend
    networks:
      - app-network
    restart: unless-stopped

# Named volumes for data persistence
volumes:
  postgres_data:
  redis_data:

# Custom network for service communication
networks:
  app-network:
    driver: bridge

Create .env:

DB_PASSWORD=mysecurepassword123

Key Docker Compose Concepts

Services: Each service defines a container. They can be built from a Dockerfile (backend, frontend) or use existing images (db, redis).

Depends_on with health checks: The backend waits for the database and Redis to be healthy before starting. This prevents the common "database connection refused" error that happens when apps start before their dependencies are ready.

Networks: Services in the same network can communicate using their service names as hostnames. The backend connects to db:5432 and redis:6379—Docker's internal DNS resolves these to the correct containers.

Volumes: postgres_data and redis_data are named volumes. Even if you delete and recreate the containers, the data persists. Without volumes, your database would reset every time you restart.

Step 6: Running the Application

Start everything:

docker compose up --build

The --build flag ensures Docker rebuilds the images if the code has changed.

On first run, you'll see Docker:

  1. Pull the PostgreSQL, Redis, and Nginx base images
  2. Build the backend and frontend images
  3. Create the network and volumes
  4. Start all containers in the correct order

Once running, access:

  • Frontend: http://localhost
  • Backend API directly: http://localhost:3001/api/data
  • Backend health: http://localhost:3001/health

Common First-Run Issues

"Connection refused" to database: The backend started before PostgreSQL finished initializing. Stop with Ctrl+C and run docker compose up again. The health check dependency usually prevents this, but race conditions can still occur on very slow machines.

Port already in use: If you have PostgreSQL or another service running locally on port 5432, change the port mapping in docker-compose.yml to "5433:5432".

Frontend shows blank page: Check the browser console. If you see CORS errors, the Nginx proxy isn't configured correctly. Verify the location /api/ block in nginx.conf.

Step 7: Production Deployment

For production, we need a few additions:

Environment Management

Never commit .env files with real secrets. Instead:

  1. Create .env.production locally (don't commit it)
  2. On the server, create the environment file manually
  3. Use Docker secrets or a secrets manager for sensitive data

Docker Compose for Production

Create docker-compose.prod.yml:

version: '3.8'

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /var/lib/docker/volumes/demo_postgres:/var/lib/postgresql/data
    networks:
      - app-network
    restart: always

  redis:
    image: redis:7-alpine
    volumes:
      - /var/lib/docker/volumes/demo_redis:/data
    networks:
      - app-network
    restart: always

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    environment:
      NODE_ENV: production
      PORT: 3001
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: myapp
      DB_USER: postgres
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
    depends_on:
      - db
      - redis
    networks:
      - app-network
    restart: always
    deploy:
      replicas: 2

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    depends_on:
      - backend
    networks:
      - app-network
    restart: always

  # Reverse proxy / SSL terminator
  nginx-proxy:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
      - ./nginx/ssl:/etc/nginx/ssl
    depends_on:
      - frontend
      - backend
    networks:
      - app-network
    restart: always

networks:
  app-network:
    driver: bridge

Deploying to a VPS

  1. Set up your server (DigitalOcean, AWS, Hetzner, etc.)
  2. Install Docker: curl -fsSL https://get.docker.com | sh
  3. Clone your repository
  4. Create the environment file: echo "DB_PASSWORD=yourpassword" > .env
  5. Start the application: docker compose -f docker-compose.prod.yml up -d

The -d flag runs containers in detached mode (background).

SSL with Let's Encrypt

For production, you need HTTPS. The easiest approach is using Traefik or Caddy as your reverse proxy—they handle Let's Encrypt certificates automatically.

Here's a Caddy-based addition to your compose file:

  caddy:
    image: caddy:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - app-network

And Caddyfile:

yourdomain.com {
    reverse_proxy frontend:80
}

api.yourdomain.com {
    reverse_proxy backend:3001
}

Caddy automatically obtains and renews SSL certificates. Zero configuration needed.

Step 8: Monitoring and Logging

In production, you need visibility into what's happening:

Viewing Logs

# All services
docker compose logs -f

# Specific service
docker compose logs -f backend

# Last 100 lines
docker compose logs --tail=100 backend

Resource Monitoring

# Container stats (CPU, memory, network)
docker stats

# Or use a monitoring stack:
# - Prometheus + Grafana for metrics
# - Loki for log aggregation

Summary: What You Learned

Through this tutorial, you've built and deployed a production-ready multi-container application. Here's what you now understand:

  1. Dockerfile best practices: Multi-stage builds, non-root users, health checks, Alpine images
  2. Docker Compose orchestration: Service dependencies, networks, volumes, health checks
  3. Production deployment: Environment management, SSL, reverse proxies
  4. Operational basics: Logging, monitoring, troubleshooting

Next Steps

  • Learn about Docker Swarm or Kubernetes for container orchestration at scale
  • Implement CI/CD with GitHub Actions to automate builds and deployments
  • Explore Docker secrets for managing sensitive configuration
  • Set up centralized logging with the ELK stack or Loki

Docker's learning curve is front-loaded, but once these concepts click, you'll deploy applications with a confidence you never had before. The consistency of "it works exactly the same everywhere" is genuinely transformative.

Categories

DockerDevOpsDeploymentTutorialWeb Development
Alex Chen

Alex Chen

Editor-in-Chief

Alex is a senior software engineer with over 10 years of experience in full-stack development, cloud infrastructure, and developer tooling. He previously led engineering teams at two SaaS startups and contributes to open-source projects in his spare time. At TechPulse, Alex oversees technical content and ensures every article is backed by real-world experience.

Software DevelopmentCloud ComputingDevOps
AdvertisementAdSense