In the previous tutorials, we learned how to build images, run containers, use Compose, and manage volumes and networks. Everything so far happened on your local computer.
Now it is time to deploy. In this tutorial, you will learn how to move your application from your laptop to a production server.
The Deployment Workflow
Deploying with Docker follows a simple pattern:
- Build the image on your computer (or in CI/CD)
- Push the image to a registry
- Pull the image on the server
- Run the container on the server
Your Computer Registry Server
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Build │────►│ Docker Hub │────►│ Pull │
│ Image │push │ or ghcr.io │pull │ & Run │
└──────────┘ └──────────────┘ └──────────┘
Docker Hub — The Default Registry
Docker Hub is the default place to store and share images. It is free for public images and offers one private repository on the free plan.
Create an Account
Go to hub.docker.com and sign up. Remember your username — you will need it for tagging images.
Log In from the Terminal
docker login
Enter your Docker Hub username and password. Docker stores your credentials so you do not need to log in every time.
Tag and Push an Image
Before pushing, your image needs a tag that includes your Docker Hub username:
# Build with the correct tag
docker build -t kemalcodes/my-app:1.0 .
# Or tag an existing image
docker tag my-app kemalcodes/my-app:1.0
# Push to Docker Hub
docker push kemalcodes/my-app:1.0
The tag format is username/image-name:version. Always use a specific version tag like 1.0 or 2.3.1. Avoid relying on latest in production.
Pull on Another Machine
On your server (or any other machine):
docker pull kemalcodes/my-app:1.0
docker run -d -p 3000:3000 kemalcodes/my-app:1.0
That is it. The same image that worked on your laptop now runs on the server.
Private Registries
Not all images should be public. For private projects, you have several options.
GitHub Container Registry (ghcr.io)
If your code is on GitHub, you can store images alongside it. GitHub Container Registry (ghcr.io) is free for public repos and included in GitHub Pro for private repos.
# Log in to GitHub Container Registry
docker login ghcr.io -u YOUR_GITHUB_USERNAME
# Tag for ghcr.io
docker build -t ghcr.io/kemalcodes/my-app:1.0 .
# Push
docker push ghcr.io/kemalcodes/my-app:1.0
# Pull on another machine
docker pull ghcr.io/kemalcodes/my-app:1.0
GitHub generates a personal access token (PAT) that you use as the password. Create one at GitHub Settings > Developer Settings > Personal Access Tokens. Make sure it has the write:packages permission.
Other Private Registries
There are many options:
- Amazon ECR — for AWS deployments
- Google Artifact Registry — for Google Cloud
- Azure Container Registry — for Azure
- Self-hosted registry — run your own with
docker run -d -p 5000:5000 registry:2
The push/pull workflow is the same for all registries. Only the URL changes.
Deploying to a VPS
A VPS (Virtual Private Server) is the simplest way to deploy Docker containers. Services like Hetzner, DigitalOcean, and Linode offer affordable VPS instances.
Step 1: Install Docker on the Server
SSH into your server:
ssh sam@your-server-ip
Install Docker:
# Ubuntu/Debian
sudo apt update
sudo apt install docker.io docker-compose-plugin
sudo systemctl enable docker
sudo systemctl start docker
# Add your user to the docker group
sudo usermod -aG docker $USER
Log out and log back in for the group change to take effect.
Step 2: Copy Your Compose File
You can copy your docker-compose.yml to the server with scp:
scp docker-compose.yml sam@your-server-ip:/home/sam/my-app/
scp .env sam@your-server-ip:/home/sam/my-app/
Or clone your repository on the server:
ssh sam@your-server-ip
git clone https://github.com/kemalcodes/my-app.git
cd my-app
Step 3: Pull and Run
If your images are on a registry:
# On the server
cd /home/sam/my-app
docker compose pull
docker compose up -d
If you build on the server:
cd /home/sam/my-app
docker compose up -d --build
Step 4: Verify
# Check if containers are running
docker compose ps
# Check logs
docker compose logs -f
# Test the app
curl http://localhost:3000
Production docker-compose.yml
A production Compose file looks different from a development one. Here is an example:
services:
app:
image: kemalcodes/my-app:1.0
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
Notice the differences from the development version:
- Uses
image:instead ofbuild:(pre-built image from registry) - Has
restart: unless-stopped - Has health checks
- Uses
.envfor secrets - Sets resource limits
- No bind mounts (no source code on the server)
Health Checks
A health check tells Docker how to verify that your container is working, not just running. A container can be “running” but the application inside might be stuck or crashed.
Health Check in Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
The health check runs curl against a /health endpoint every 30 seconds. If it fails 3 times in a row, Docker marks the container as unhealthy.
Health Check in Compose
services:
app:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
The start_period gives the application time to start before health checks begin.
Checking Health Status
docker ps
The STATUS column shows healthy, unhealthy, or starting:
CONTAINER ID IMAGE STATUS NAMES
a1b2c3d4e5f6 my-app Up 5 min (healthy) my-app
Restart Policies
In production, you want containers to restart automatically if they crash or if the server reboots.
| Policy | Behavior |
|---|---|
no | Do not restart (default) |
always | Always restart, including after reboot |
unless-stopped | Restart unless manually stopped |
on-failure | Restart only if the container exits with an error |
The best choice for production is unless-stopped:
services:
app:
image: my-app:1.0
restart: unless-stopped
This restarts the container if it crashes or if the server reboots. But if you manually run docker compose stop, it stays stopped.
Logging
Docker captures everything a container writes to stdout and stderr.
Viewing Logs
# View logs
docker logs my-app
# Follow logs in real-time
docker logs -f my-app
# Last 100 lines
docker logs --tail 100 my-app
# Logs since a specific time
docker logs --since 2h my-app
Log Rotation
By default, Docker does not limit log size. On a busy server, logs can fill up the disk. Add log rotation in your Compose file:
services:
app:
image: my-app:1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
This keeps a maximum of 3 log files, each up to 10 MB. When the limit is reached, Docker rotates the logs automatically.
You can also set this globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Restart Docker after changing the daemon config:
sudo systemctl restart docker
Security Basics
Running Docker in production requires attention to security.
Run as Non-Root
Always use a non-root user in your Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --production
USER node
CMD ["node", "server.js"]
Use Minimal Base Images
Smaller images have fewer packages and fewer potential vulnerabilities.
| Image | Size | Security |
|---|---|---|
node:20 | ~350 MB | More packages, more attack surface |
node:20-slim | ~180 MB | Fewer packages |
node:20-alpine | ~50 MB | Minimal packages, smaller attack surface |
Use -alpine or -slim variants when possible.
Scan for Vulnerabilities
Docker Scout scans your images for known vulnerabilities:
docker scout cves my-app:1.0
This shows a list of vulnerabilities (CVEs) in your image. Fix them by updating your base image or dependencies.
Do Not Store Secrets in Images
Never put passwords, API keys, or certificates in your Dockerfile or image. Use environment variables or Docker secrets:
# Good — secrets in .env file
environment:
- DATABASE_URL=${DATABASE_URL}
# Bad — secrets hardcoded
environment:
- DATABASE_URL=postgres://admin:password123@db:5432/prod
Updating a Deployment
When you have a new version of your app:
# On your computer: build and push the new version
docker build -t kemalcodes/my-app:1.1 .
docker push kemalcodes/my-app:1.1
# On the server: update and restart
ssh sam@your-server-ip
cd /home/sam/my-app
# Update the image tag in docker-compose.yml
# Then:
docker compose pull
docker compose up -d
Docker Compose detects that the image changed and recreates only the affected containers. Your database container and its volume are not touched.
Brief Overview: Container Orchestration
For a single server, Docker Compose is enough. But when you need to run containers across multiple servers, you need an orchestration tool.
Kubernetes
Kubernetes (K8s) is the most popular container orchestration platform. It manages containers across a cluster of machines. It handles:
- Scaling (running more copies of your app)
- Load balancing (distributing traffic)
- Self-healing (restarting failed containers)
- Rolling updates (deploying without downtime)
Kubernetes is powerful but complex. You do not need it unless you have multiple servers or need automatic scaling.
Docker Swarm
Docker Swarm is Docker’s built-in orchestration tool. It is simpler than Kubernetes and uses the same Compose file format. It is a good middle ground if Kubernetes feels too complex.
# Initialize a Swarm
docker swarm init
# Deploy a stack from a Compose file
docker stack deploy -c docker-compose.yml my-app
When to Use What
| Scenario | Tool |
|---|---|
| Single server, few services | Docker Compose |
| Multiple servers, need scaling | Kubernetes or Docker Swarm |
| Managed cloud deployment | Cloud Run, AWS ECS, or Azure Container Apps |
For most small to medium projects, a single server with Docker Compose is more than enough. Do not add orchestration complexity until you actually need it.
Common Mistakes
1. Using latest tag in production. The latest tag is mutable — it points to whatever was pushed last. If someone pushes a broken version, your server pulls it on the next restart. Always use specific version tags like 1.0, 1.1, 2.0.
2. Not setting up log rotation. Without log limits, containers can fill the disk. Add max-size and max-file to your logging configuration or to the Docker daemon config.
3. Exposing the database port to the internet. In development, you might map 5432:5432 for the database. In production, remove this. The app container can reach the database through the internal Docker network. The database should not be accessible from the outside.
What We Learned
In this tutorial, you learned:
- The deployment workflow: build, push, pull, run
- Docker Hub and ghcr.io store images for sharing and deployment
docker pushanddocker pullmove images between machines- Deploy to a VPS by copying your Compose file and running
docker compose up -d - Health checks verify that containers are actually working
- Restart policies (
unless-stopped) keep containers running after crashes - Log rotation prevents disk space issues
- Security basics: non-root user, minimal images, vulnerability scanning
- Kubernetes and Docker Swarm handle multi-server deployments
Related Articles
- Docker Tutorial #1: What is Docker — containers, images, and registries
- Docker Tutorial #2: Dockerfile — building custom images
- Docker Tutorial #3: Docker Compose — running multiple containers
- Docker Tutorial #4: Volumes and Networking — persistent data and container communication
- Docker Cheat Sheet — quick reference for all Docker commands
What’s Next?
This is the last article in the Docker mini-series. You now know enough to containerize, manage, and deploy applications with Docker.
If you are following the full DevTools series, check out the SQL tutorials where we cover database fundamentals — from basic queries to performance optimization. Combine what you learned about Docker with SQL when you set up PostgreSQL in a container.