Skip to content

Docker Compose Production Deploy on Ubuntu

This workflow shows a production setup which uses a Docker Compose stack on an Ubuntu server such as EC2. We cover here steps for Docker install, env file managemtent, first build and startup, logs, rebuild flows, and keeping the stack running across reboots via systemd. Assumes nginx on the host is already configured to proxy to the local container ports.

Use Docker’s official apt repository for current versions of the engine, Compose plugin, and Buildx.

Terminal window
sudo apt update
sudo apt install -y ca-certificates curl
# Add Docker's GPG key
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add Docker's apt repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install engine, Compose plugin, and Buildx plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Enable the Docker service and allow the current user to run docker without sudo. Re-login is required for the group membership to take effect.

Terminal window
sudo systemctl enable docker
sudo systemctl start docker
sudo usermod -aG docker $USER
exit
Terminal window
cd /home/$USER
git clone https://github.com/your-org/your-app.git myapp
cd myapp
git status

Keep a single central production env file at the repo root. Backend and worker services load it via env_file in compose.prod.yaml. Frontend build-time variables like NEXT_PUBLIC_* are passed separately as build args.

Terminal window
nano /home/$USER/myapp/.env.prod

Typical values to set:

Terminal window
NEXT_PUBLIC_API_URL=https://api.example.com
DATABASE_URL=<your database connection string>
REDIS_URL=redis://redis:6379/0
# ... additional backend secrets

A minimal template showing the production patterns:

Terminal window
services:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
restart: unless-stopped
env_file:
- .env.prod
depends_on:
- redis
ports:
- "127.0.0.1:8000:8000"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
restart: unless-stopped
environment:
NODE_ENV: production
depends_on:
- backend
ports:
- "127.0.0.1:3000:3000"
volumes:
redis_data:

If host-level services from the previous setup are still binding the app ports (e.g. an old systemd unit on 3000 or 8000), stop them first. Then build images and bring up the stack detached.

Terminal window
cd /home/$USER/myapp
# Stop any host services that may still hold the ports
sudo systemctl stop <old-host-service> 2>/dev/null || true
# Build, then start detached
docker compose --env-file .env.prod -f compose.prod.yaml build
docker compose --env-file .env.prod -f compose.prod.yaml up -d
docker compose --env-file .env.prod -f compose.prod.yaml ps

Tail recent output or follow live.

Terminal window
docker compose --env-file .env.prod -f compose.prod.yaml logs --tail=100 backend
docker compose --env-file .env.prod -f compose.prod.yaml logs -f frontend

Confirm local ports are reachable and that the expected process is listening.

Terminal window
curl http://127.0.0.1:8000
curl http://127.0.0.1:3000
sudo ss -ltnp | grep -E ':3000|:8000'
  • Use Docker Compose logs for application container output.
  • Use nginx access logs to see browser requests, page loads, static asset requests, and API routing through the public domain.
  • Use nginx error logs to diagnose proxy, upstream, TLS, and routing errors.
  • Production Next.js logs are usually quieter than npm run dev. A normal browser page load may not produce frontend container logs.
  • Client-side browser console logs are not shown in Docker logs. Use the browser DevTools Console and Network tabs for client-side errors.
Terminal window
cd /home/$USER/myapp
# Follow live frontend container logs
docker compose --env-file .env.prod -f compose.prod.yaml logs --tail=200 -f frontend
# Follow live backend container logs
docker compose --env-file .env.prod -f compose.prod.yaml logs --tail=200 -f backend
# Follow frontend and backend together while reproducing an issue
docker compose --env-file .env.prod -f compose.prod.yaml logs --tail=200 -f frontend backend

Use nginx logs when you need to confirm whether the browser request reached the server and which route/status nginx returned.

Terminal window
# Follow public request logs
sudo tail -f /var/log/nginx/access.log
# Follow nginx error logs
sudo tail -f /var/log/nginx/error.log
# Follow both nginx logs at the same time
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
# Filter for common auth and frontend routes
sudo tail -f /var/log/nginx/access.log | grep -E 'api|auth|register|signup|login|_next'

Use the raw Docker container logs when you know the exact container name and want to bypass Compose service naming.

Terminal window
# List running containers
docker ps
# Follow logs for a specific container
docker logs --tail=200 -f <frontend-container-name>
docker logs --tail=200 -f <backend-container-name>

Use systemd logs only for the Compose wrapper service itself. These logs are useful for boot/start/stop failures, but Docker Compose logs are better for application runtime errors.

Terminal window
sudo systemctl status app-compose
journalctl -u app-compose -n 100 --no-pager
journalctl -u app-compose -f
  • If nginx access logs show the request but backend logs show nothing, the request may not be routed to the backend container.
  • If nginx shows 4xx or 5xx responses, check nginx error logs and backend logs together.
  • If Docker frontend logs show nothing during a page load, that can be normal in production.
  • If the error is visible only in the browser, check DevTools Network response body and Console output.

Rebuild only the frontend image when frontend code or frontend build-time env changes. –no-cache guarantees public env values like NEXT_PUBLIC_API_URL are baked in fresh.

Terminal window
cd /home/$USER/myapp
# single service build and up shortcut:
docker compose --env-file .env.prod -f compose.prod.yaml up -d --build frontend
# in two steps (and the only up version without build)
docker compose --env-file .env.prod -f compose.prod.yaml build frontend
docker compose --env-file .env.prod -f compose.prod.yaml up -d frontend
# for validation/debug
docker compose --env-file .env.prod -f compose.prod.yaml logs --tail=100 frontend

Inspect the built bundle to confirm the production API URL was baked in and no localhost references leaked through.

Terminal window
docker compose --env-file .env.prod -f compose.prod.yaml exec frontend sh -lc "grep -R 'localhost:8000' /app/.next | head -20"
docker compose --env-file .env.prod -f compose.prod.yaml exec frontend sh -lc "grep -R 'api.example.com' /app/.next | head -20"

Pull latest code, rebuild all services, and recreate.

Terminal window
cd /home/$USER/myapp
git pull
docker compose --env-file .env.prod -f compose.prod.yaml up -d --build
docker compose --env-file .env.prod -f compose.prod.yaml ps

Run one-time backend migration and seed scripts inside the backend container. This uses the same production env file and Compose file as the deployed stack.

Terminal window
cd /home/$USER/myapp
# Verify migration files exist inside the backend image
docker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/migrations
# Verify script files exist inside the backend image
docker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/scripts

If /app/scripts is missing or the target script is not listed, rebuild the backend image before running the script.

Terminal window
# Rebuild backend if scripts or migrations are missing from the image
docker compose --env-file .env.prod -f compose.prod.yaml build --no-cache backend
# Recheck files after rebuild
docker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/scripts
docker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/migrations

Run the migration first when the seed script depends on tables created by that migration.

Terminal window
# Create required database tables first
docker compose --env-file .env.prod -f compose.prod.yaml run --rm backend python migrations/sample_migration.py
# Seed data after the tables exist
docker compose --env-file .env.prod -f compose.prod.yaml run --rm backend python scripts/sample_script.py

Run only the script when the required tables already exist.

Terminal window
docker compose --env-file .env.prod -f compose.prod.yaml run --rm backend python scripts/sample_script.py

Docker’s restart: unless-stopped policies only resume containers if the Compose stack was already up. A systemd oneshot unit runs docker compose up on boot and down on shutdown, making sure the stack comes back cleanly after reboots.

Terminal window
sudo nano /etc/systemd/system/app-compose.service

Paste the following and save. Adjust WorkingDirectory to match the repo path on your host.

Terminal window
[Unit]
Description=App Docker Compose stack
Requires=docker.service
After=docker.service network.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/ubuntu/myapp
ExecStart=/usr/bin/docker compose --env-file .env.prod -f compose.prod.yaml up -d
ExecStop=/usr/bin/docker compose --env-file .env.prod -f compose.prod.yaml down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target

Reload systemd, enable the service so it runs on boot, and start it now.

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable app-compose
sudo systemctl start app-compose
sudo systemctl status app-compose
# View service-level logs
journalctl -u app-compose -n 100 --no-pager
Terminal window
cd /home/$USER/myapp
# Deploy latest
git pull
docker compose --env-file .env.prod -f compose.prod.yaml up -d --build
# Check status
docker compose --env-file .env.prod -f compose.prod.yaml ps
# Restart one service
docker compose --env-file .env.prod -f compose.prod.yaml restart backend
docker compose --env-file .env.prod -f compose.prod.yaml restart frontend
# Stop / start the whole stack
docker compose --env-file .env.prod -f compose.prod.yaml down
docker compose --env-file .env.prod -f compose.prod.yaml up -d
  • Keep a single central production env file at the repo root (.env.prod).
  • Runtime secrets for backend and worker services load through env_file in compose.prod.yaml.
  • Inside Compose, service-to-service hostnames use service names (e.g. redis://redis:6379/0), not localhost.
  • Bind container ports to 127.0.0.1 so nginx on the host stays the only public entry point.
  • restart: unless-stopped alone does not survive full host reboots cleanly, thus the systemd unit above is what guarantees the stack comes back on boot.
  • Docker Compose logs show application container stdout and stderr, not every browser request.
  • nginx access logs are the main place to confirm public browser requests and HTTP status codes.
  • nginx error logs are the main place to diagnose proxy, upstream, TLS, and routing failures.
  • Production frontend logs are quieter than local development logs. Use browser DevTools for client-side console and network errors.