Deploy Docker Compose on Ubuntu 26.04 LTS

Docker

By Jennifer Webb

Updated on Aug 11, 2026

Deploy Docker Compose on Ubuntu 26.04 LTS

What you will deploy

This tutorial shows how to deploy a Docker Compose application on a dedicated server running Ubuntu 26.04 LTS, from the first SSH login through firewall setup, reverse proxying with Nginx, and final verification. The example uses a small web app with a separate backend container and a Redis service, which mirrors a common hosting workload for startups, agencies, and internal tools. You will create a non-root sudo user, lock down SSH with keys, install Docker, launch the stack with Compose, publish it safely behind Nginx, and verify that it survives a reboot.

If you do not already have the server, use a dedicated server only: https://hostnextra.com/dedicated-server. For context on adjacent workflows, see Deploy Docker Compose on a Fresh Dedicated Server, How to Install Docker on Ubuntu 26.04 LTS, and How to Install Certbot SSL on Ubuntu 26.04 with Nginx.

Prerequisites, access, and network plan

Use these placeholders throughout the guide:

  • SERVER_IP=203.0.113.10
  • SSH_PORT=22
  • ADMIN_USER=deploy
  • DOMAIN_NAME=app.example.com
  • APP_DIR=/opt/example-app
  • APP_PORT=8000
  • DB_PASSWORD=replace-with-a-long-random-secret
  • SECRET_KEY=replace-with-a-long-random-secret

You need one dedicated server with Ubuntu 26.04 LTS, a domain name pointed to the server’s public IP, and inbound access to TCP 22 for SSH, TCP 80 for HTTP, and TCP 443 for HTTPS. If your server provider gives you a web console, keep it available while you make changes. If you are creating a new non-root account, keep the original root session open until the new sudo user can connect and run sudo successfully.

Connect to the server and confirm the operating system

Local computer: open a terminal and connect with SSH. Replace the user, IP address, and port if your environment differs.

ssh root@SERVER_IP

If your host uses a custom SSH port, use this form instead:

ssh -p SSH_PORT root@SERVER_IP

After login, VPS as root: confirm the OS before any Ubuntu-specific commands.

cat /etc/os-release

You should see Ubuntu 26.04 LTS in the output. If the system is not Ubuntu 26.04 LTS, stop here and use the correct guide for that platform.

Create a sudo user and add your SSH key

These steps reduce risk before you install the application. Run them as VPS as root.

adduser deploy

This creates the account and prompts you to set a password. Use a strong temporary password if you need password fallback during setup.

usermod -aG sudo deploy

This adds the user to the sudo group. The account will be able to run administrative commands after you test it.

install -d -m 700 -o deploy -g deploy /home/deploy/.ssh

This creates the SSH directory with the correct ownership and permissions.

Local computer: copy your public key to the server. Replace the path with your own key file.

ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@SERVER_IP

If you use a custom SSH port:

ssh-copy-id -i ~/.ssh/id_ed25519.pub -p SSH_PORT deploy@SERVER_IP

If you prefer to install the key manually, use these VPS as root commands and paste your public key into the file exactly once:

cat > /home/deploy/.ssh/authorized_keys <<'EOF'
PASTE_YOUR_PUBLIC_KEY_HERE
EOF
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys

Now test the new account in a second terminal. Keep the original root session open.

Local computer:

ssh deploy@SERVER_IP

Verify sudo works from the new session:

sudo -v

Expected result: you are prompted for the deploy user password once, or your sudo token is accepted. Only after this succeeds should you consider disabling root password login later, if your policy requires it.

Update Ubuntu, set time sync, and prepare directories

Run the following as VPS as the named sudo user.

sudo apt update
sudo apt full-upgrade -y
sudo apt install -y ca-certificates curl gnupg lsb-release openssl git
sudo timedatectl set-ntp true
sudo timedatectl status

These commands refresh packages, install tools needed for Docker and TLS work, and turn on NTP. The status output should show network time synchronization as enabled.

sudo mkdir -p /opt/example-app/{app,nginx}
sudo chown -R deploy:deploy /opt/example-app

This creates the application layout under /opt, which is common for dedicated-server deployments.

Install Docker Engine and Compose plugin

Use the official Docker repository on Ubuntu 26.04. Run these commands as VPS as the named sudo user.

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
. /etc/os-release
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu %s stable\n' "$(dpkg --print-architecture)" "$VERSION_CODENAME" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

These commands add Docker’s repository, refresh package metadata, and install the engine plus the Compose plugin. Confirm the versions:

docker --version
docker compose version

Expected result: both commands print version information. Then let your sudo user run Docker without prefixing every command with sudo:

sudo usermod -aG docker deploy

Log out and back in, then test:

docker ps

If Docker still reports a permission error, sign out completely and reconnect before continuing.

Review the application files

Create a simple containerized app so the deployment stays realistic and self-contained. In this example, Nginx will proxy to a Python app on port 8000 and Redis will serve as a cache. Run these commands as VPS as the named sudo user.

cat > /opt/example-app/app/app.py <<'EOF'
from http.server import BaseHTTPRequestHandler, HTTPServer
import os

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body = f"OK from {os.getenv('APP_NAME', 'example-app')}\n"
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write(body.encode())

HTTPServer(('0.0.0.0', int(os.getenv('APP_PORT', '8000'))), Handler).serve_forever()
EOF

This creates a minimal service that always returns a plain-text health response.

cat > /opt/example-app/app/Dockerfile <<'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY app.py /app/app.py
ENV APP_NAME=example-app
EXPOSE 8000
CMD ["python", "/app/app.py"]
EOF

This container image runs the app on port 8000.

cat > /opt/example-app/.env <<'EOF'
APP_PORT=8000
SECRET_KEY=replace-with-a-long-random-secret
EOF
chmod 600 /opt/example-app/.env

The environment file should remain readable only by the owner. Replace the secret with a unique value.

cat > /opt/example-app/compose.yaml <<'EOF'
services:
  app:
    build:
      context: ./app
    env_file:
      - .env
    restart: unless-stopped
    expose:
      - "8000"
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis-data:/data
    command: ["redis-server", "--appendonly", "yes"]

volumes:
  redis-data:
EOF

This Compose file defines the app and Redis service, persists Redis data, and keeps both services set to restart automatically.

Build and start the Compose stack

Run the following as VPS as the named sudo user from inside /opt/example-app.

cd /opt/example-app
docker compose config
docker compose up -d --build

docker compose config validates the file before anything starts. If the syntax is wrong, fix it before proceeding. The up -d --build command then builds the app image and starts the containers in the background.

Check the running containers and the logs:

docker compose ps
docker compose logs --tail=50

Expected result: both services should be healthy enough to stay running, and the app container should not exit with an error.

Install and configure Nginx as a reverse proxy

Install Nginx so public traffic goes to the app on localhost rather than directly to the container. Run this as VPS as the named sudo user.

sudo apt install -y nginx
nginx -v

The version check confirms the package installed successfully.

Create the site file with a complete server block. This command runs as VPS as the named sudo user.

sudo tee /etc/nginx/sites-available/example-app > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        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;
    }
}
EOF

Replace app.example.com with your own domain. This file tells Nginx to forward requests to the app container through the host’s loopback address.

sudo ln -s /etc/nginx/sites-available/example-app /etc/nginx/sites-enabled/example-app
sudo nginx -t

nginx -t must return a successful syntax check before any reload. If it fails, edit the file and run the test again.

sudo systemctl reload nginx
sudo systemctl enable nginx
sudo systemctl status nginx --no-pager

The reload applies the config without dropping existing connections, and enable-at-boot makes the change persistent.

Open the firewall for SSH, HTTP, and HTTPS

Ubuntu uses UFW for a straightforward host firewall. Run this as VPS as the named sudo user.

sudo apt install -y ufw
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status verbose

Expected result: SSH, HTTP, and HTTPS are allowed, and the firewall reports active. If you change the SSH port later, add that port before enabling stricter rules.

Add TLS with Let’s Encrypt

When DNS for app.example.com points to SERVER_IP, you can request a certificate. Run this as VPS as the named sudo user.

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com

Follow the prompt to provide an email address and accept the terms. Certbot will update the Nginx configuration and redirect HTTP to HTTPS if you choose that option.

Validate the renewal path:

sudo certbot renew --dry-run

If this dry run succeeds, the renewal timer is configured correctly.

Smoke test the site and application

Test from the server first, then from your client. Run the following as VPS as the named sudo user:

curl -I http://127.0.0.1
curl https://app.example.com

The first command should return a valid HTTP response from Nginx on the server itself. The second should return the app’s plain-text message over HTTPS once DNS and TLS are in place.

Check the listening sockets:

sudo ss -tulpn | grep -E '(:80|:443|:22)'

Expected result: Nginx listens on 80 and 443, and SSH listens on 22 or your custom port.

Also confirm the Docker services are still running:

docker compose ps

Reboot test and rollback path

Run a reboot test to confirm the deployment survives a restart. Do this as VPS as the named sudo user, and keep the root console available until the server returns.

sudo reboot

After the server comes back, reconnect with SSH and run:

docker compose -f /opt/example-app/compose.yaml ps
sudo systemctl status nginx --no-pager
sudo ufw status verbose

If the app fails to return, use these checks in order:

cd /opt/example-app
docker compose logs --tail=100
sudo nginx -t
sudo systemctl status nginx --no-pager

Look for container crashes, a bad Nginx syntax report, or a missing DNS record. The corrective action is usually to fix the file, re-run the syntax check, and reload Nginx only after the test passes.

Common problems and exact fixes

  • Docker permission denied: run groups to confirm the user is in the docker group, then log out and back in. If it still fails, run sudo usermod -aG docker deploy again and reconnect.
  • Nginx returns 502: run docker compose ps and docker compose logs --tail=50. If the app exited, rebuild with docker compose up -d --build.
  • Certbot cannot issue a certificate: run dig +short app.example.com from your local computer and confirm it resolves to SERVER_IP. Fix DNS first, then rerun sudo certbot --nginx -d app.example.com.
  • Firewall blocks access: run sudo ufw status numbered. If SSH was not allowed, add it before removing any working rule.

Why this pattern fits dedicated servers

This layout works well on dedicated infrastructure because you keep the public edge simple, run the app in isolated containers, and retain direct control over storage, networking, and access policy. It also scales into a more formal hosting workflow: separate stacks by directory, add monitoring later, and place reverse-proxied applications behind one Nginx front end. If you are still evaluating hardware, read How to Choose a Dedicated Server in 2026 and How to Compare Dedicated Server Options in 2026 before you buy.

Need a dedicated server for this deployment? Host your stack on dedicated infrastructure with full root access, NVMe storage, and responsive support: View dedicated servers.

FAQ

Can I run more than one Compose project on the same server?

Yes. Keep each project in its own directory, use different internal ports or separate reverse-proxy hostnames, and validate each Compose file with docker compose config before starting it.

Should I expose container ports directly to the internet?

No. Keep the app bound to localhost or an internal Docker network, then publish only Nginx on ports 80 and 443. That gives you one hardened entry point and avoids accidental exposure.

What should I do before editing firewall rules?

Keep the current SSH session open, add new allow rules before removing old ones, and verify access from a second terminal before you close the root console.

How do I update the app later?

Edit the app files, rebuild with docker compose up -d --build, check docker compose logs, and only then reload any proxy or TLS changes if they were part of the update.