Ctrl K

Docker Compose Production Deploy on Ubuntu

End-to-end Docker Compose production deployment on an Ubuntu server: install Docker, run the stack, keep after reboots with systemd, and day-to-day rebuild flows.

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.

Install Docker on Ubuntu

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

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.

sudo systemctl enable docker
sudo systemctl start docker
sudo usermod -aG docker $USER
exit

Clone the project

cd /home/$USER
git clone https://github.com/your-org/your-app.git myapp
cd myapp
git status

Create the production env file

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.

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

Typical values to set:

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

compose.prod.yaml skeleton

A minimal template showing the production patterns:

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:

First build and start

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.

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

Logs and health checks

Tail recent output or follow live.

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.

curl http://127.0.0.1:8000
curl http://127.0.0.1:3000
sudo ss -ltnp | grep -E ':3000|:8000'

Production request and log monitoring

  • 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.
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.

# 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.

# 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.

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.

Frontend-only rebuild

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.

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.

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"

Full rebuild and update

Pull latest code, rebuild all services, and recreate.

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 backend migrations and scripts

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.

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.

# 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.

# 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.

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

Persist across reboots with systemd

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.

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

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

[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.

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

Day-to-day operations

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

Operational notes

  • 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.