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_IPIf your host uses a custom SSH port, use this form instead:
ssh -p SSH_PORT root@SERVER_IPAfter login, VPS as root: confirm the OS before any Ubuntu-specific commands.
cat /etc/os-releaseYou 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 deployThis 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 deployThis 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/.sshThis 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_IPIf you use a custom SSH port:
ssh-copy-id -i ~/.ssh/id_ed25519.pub -p SSH_PORT deploy@SERVER_IPIf 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_keysNow test the new account in a second terminal. Keep the original root session open.
Local computer:
ssh deploy@SERVER_IPVerify sudo works from the new session:
sudo -vExpected 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 statusThese 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-appThis 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-pluginThese commands add Docker’s repository, refresh package metadata, and install the engine plus the Compose plugin. Confirm the versions:
docker --version
docker compose versionExpected result: both commands print version information. Then let your sudo user run Docker without prefixing every command with sudo:
sudo usermod -aG docker deployLog out and back in, then test:
docker psIf 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()
EOFThis 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"]
EOFThis 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/.envThe 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:
EOFThis 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 --builddocker 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=50Expected 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 -vThe 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;
}
}
EOFReplace 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 -tnginx -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-pagerThe 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 verboseExpected 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.comFollow 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-runIf 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.comThe 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 psReboot 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 rebootAfter 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 verboseIf 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-pagerLook 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
groupsto confirm the user is in thedockergroup, then log out and back in. If it still fails, runsudo usermod -aG docker deployagain and reconnect. - Nginx returns 502: run
docker compose psanddocker compose logs --tail=50. If the app exited, rebuild withdocker compose up -d --build. - Certbot cannot issue a certificate: run
dig +short app.example.comfrom your local computer and confirm it resolves toSERVER_IP. Fix DNS first, then rerunsudo 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.

