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.
Install Docker on Ubuntu
Section titled “Install Docker on Ubuntu”Use Docker’s official apt repository for current versions of the engine, Compose plugin, and Buildx.
sudo apt updatesudo apt install -y ca-certificates curl
# Add Docker's GPG keysudo install -m 0755 -d /etc/apt/keyringssudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.ascsudo chmod a+r /etc/apt/keyrings/docker.asc
# Add Docker's apt repositoryecho \ "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 pluginsudo apt updatesudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginEnable 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 dockersudo systemctl start dockersudo usermod -aG docker $USERexitClone the project
Section titled “Clone the project”cd /home/$USERgit clone https://github.com/your-org/your-app.git myappcd myappgit statusCreate the production env file
Section titled “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.prodTypical values to set:
NEXT_PUBLIC_API_URL=https://api.example.comDATABASE_URL=<your database connection string>REDIS_URL=redis://redis:6379/0# ... additional backend secretscompose.prod.yaml skeleton
Section titled “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
Section titled “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 portssudo systemctl stop <old-host-service> 2>/dev/null || true
# Build, then start detacheddocker compose --env-file .env.prod -f compose.prod.yaml builddocker compose --env-file .env.prod -f compose.prod.yaml up -ddocker compose --env-file .env.prod -f compose.prod.yaml psLogs and health checks
Section titled “Logs and health checks”Tail recent output or follow live.
docker compose --env-file .env.prod -f compose.prod.yaml logs --tail=100 backenddocker compose --env-file .env.prod -f compose.prod.yaml logs -f frontendConfirm local ports are reachable and that the expected process is listening.
curl http://127.0.0.1:8000curl http://127.0.0.1:3000sudo ss -ltnp | grep -E ':3000|:8000'Production request and log monitoring
Section titled “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 logsdocker compose --env-file .env.prod -f compose.prod.yaml logs --tail=200 -f frontend
# Follow live backend container logsdocker compose --env-file .env.prod -f compose.prod.yaml logs --tail=200 -f backend
# Follow frontend and backend together while reproducing an issuedocker compose --env-file .env.prod -f compose.prod.yaml logs --tail=200 -f frontend backendUse nginx logs when you need to confirm whether the browser request reached the server and which route/status nginx returned.
# Follow public request logssudo tail -f /var/log/nginx/access.log
# Follow nginx error logssudo tail -f /var/log/nginx/error.log
# Follow both nginx logs at the same timesudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
# Filter for common auth and frontend routessudo 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 containersdocker ps
# Follow logs for a specific containerdocker 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-composejournalctl -u app-compose -n 100 --no-pagerjournalctl -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
Section titled “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 frontenddocker compose --env-file .env.prod -f compose.prod.yaml up -d frontend
# for validation/debugdocker compose --env-file .env.prod -f compose.prod.yaml logs --tail=100 frontendInspect 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
Section titled “Full rebuild and update”Pull latest code, rebuild all services, and recreate.
cd /home/$USER/myappgit pulldocker compose --env-file .env.prod -f compose.prod.yaml up -d --builddocker compose --env-file .env.prod -f compose.prod.yaml psRun backend migrations and scripts
Section titled “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 imagedocker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/migrations
# Verify script files exist inside the backend imagedocker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/scriptsIf /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 imagedocker compose --env-file .env.prod -f compose.prod.yaml build --no-cache backend
# Recheck files after rebuilddocker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/scriptsdocker compose --env-file .env.prod -f compose.prod.yaml run --rm backend ls -la /app/migrationsRun the migration first when the seed script depends on tables created by that migration.
# Create required database tables firstdocker compose --env-file .env.prod -f compose.prod.yaml run --rm backend python migrations/sample_migration.py
# Seed data after the tables existdocker compose --env-file .env.prod -f compose.prod.yaml run --rm backend python scripts/sample_script.pyRun 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.pyPersist across reboots with systemd
Section titled “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.servicePaste the following and save. Adjust WorkingDirectory to match the repo path on your host.
[Unit]Description=App Docker Compose stackRequires=docker.serviceAfter=docker.service network.target
[Service]Type=oneshotRemainAfterExit=yesWorkingDirectory=/home/ubuntu/myappExecStart=/usr/bin/docker compose --env-file .env.prod -f compose.prod.yaml up -dExecStop=/usr/bin/docker compose --env-file .env.prod -f compose.prod.yaml downTimeoutStartSec=0
[Install]WantedBy=multi-user.targetReload systemd, enable the service so it runs on boot, and start it now.
sudo systemctl daemon-reloadsudo systemctl enable app-composesudo systemctl start app-composesudo systemctl status app-compose
# View service-level logsjournalctl -u app-compose -n 100 --no-pagerDay-to-day operations
Section titled “Day-to-day operations”cd /home/$USER/myapp
# Deploy latestgit pulldocker compose --env-file .env.prod -f compose.prod.yaml up -d --build
# Check statusdocker compose --env-file .env.prod -f compose.prod.yaml ps
# Restart one servicedocker compose --env-file .env.prod -f compose.prod.yaml restart backenddocker compose --env-file .env.prod -f compose.prod.yaml restart frontend
# Stop / start the whole stackdocker compose --env-file .env.prod -f compose.prod.yaml downdocker compose --env-file .env.prod -f compose.prod.yaml up -dOperational notes
Section titled “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.