Docker in Practice: Deploying Your First Web Application from Scratch
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:
-
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.
-
Alpine Linux: The
node:20-alpineimage is ~40MB vs ~900MB for the full Debian-based image. In production, smaller images mean faster deployments and less attack surface. -
Non-root user: Running as
rootinside a container is a security risk. We create anodejsuser and run the app under it. -
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:
- Serves the React app's static files
- 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:
- Pull the PostgreSQL, Redis, and Nginx base images
- Build the backend and frontend images
- Create the network and volumes
- 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:
- Create
.env.productionlocally (don't commit it) - On the server, create the environment file manually
- 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
- Set up your server (DigitalOcean, AWS, Hetzner, etc.)
- Install Docker:
curl -fsSL https://get.docker.com | sh - Clone your repository
- Create the environment file:
echo "DB_PASSWORD=yourpassword" > .env - 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:
- Dockerfile best practices: Multi-stage builds, non-root users, health checks, Alpine images
- Docker Compose orchestration: Service dependencies, networks, volumes, health checks
- Production deployment: Environment management, SSL, reverse proxies
- 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
Alex Chen
Editor-in-ChiefAlex 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.