What You'll End Up With
By the end of this Docker tutorial for self-hosted apps, you'll have Docker Engine installed on a fresh Ubuntu 24.04 VPS, a containerized web app running behind an Nginx reverse proxy, and a systemd drop-in that restarts the container on reboot. Every command is tested on a Hetzner CX22 (2 vCPU, 4 GB RAM, ~$4.50/month) — the same box I use in production.
Prerequisites
- Ubuntu 24.04 LTS server (root or a user with
sudo) - SSH access
- A domain or IP address you can test against
- Basic comfort with a terminal
1. Prepare the System
Update package metadata and install prerequisite tools Docker's install script depends on.
sudo apt-get update && sudo apt-get install -y \
ca-certificates \
curl \
gnupg \
lsb-release
Expected output ends with something like:
Setting up gnupg (2.4.4-2ubuntu17) ...
2. Install Docker Engine
Step 1 — Add Docker's official GPG key
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
This places Docker's signing key at /etc/apt/keyrings/docker.gpg so APT can verify packages.
Step 2 — Add the Docker APT repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
This writes a single-line source entry for the stable Docker channel pinned to your Ubuntu release codename (noble on 24.04).
Step 3 — Install Docker Engine and the Compose plugin
sudo apt-get update && sudo apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
Installs Docker Engine, the CLI, containerd, Buildx, and the docker compose v2 plugin in one pass.
Step 4 — Allow your user to run Docker without sudo
sudo usermod -aG docker $USER
newgrp docker
Adds your current user to the docker group. newgrp docker activates the group in the current shell without requiring a logout.
3. Verify the Docker Installation
Run the official smoke-test image.
docker run --rm hello-world
Expected output (truncated):
Hello from Docker!
This message shows that your installation appears to be working correctly.
Check the installed versions:
docker --version
docker compose version
Docker version 26.1.4, build 5650f9b
Docker Compose version v2.27.1
Version numbers will differ if Docker has released a newer build; what matters is that both commands return output without errors.
4. Deploy a Self-Hosted App with Docker Compose
This section uses Uptime Kuma — a lightweight self-hosted monitoring dashboard — as the example app. Swap it for any image you prefer; the pattern is identical.
Step 1 — Create a project directory
mkdir -p ~/apps/uptime-kuma && cd ~/apps/uptime-kuma
Keeping each app in its own directory makes docker compose commands unambiguous and backups straightforward.
Step 2 — Write the Compose file
cat > compose.yaml << 'EOF'
services:
uptime-kuma:
image: louislam/uptime-kuma:1.23.13
container_name: uptime-kuma
restart: unless-stopped
ports:
- "127.0.0.1:3001:3001"
volumes:
- uptime-kuma-data:/app/data
volumes:
uptime-kuma-data:
EOF
Key decisions:
imageis pinned to1.23.13— never uselatestin production; it makes rollbacks impossible.portsbinds only to127.0.0.1so the app is not exposed directly to the internet; Nginx will proxy it.restart: unless-stoppedmeans Docker restarts the container after a crash or reboot, but not if you explicitly stop it withdocker compose stop.- The named volume
uptime-kuma-datapersists data across container rebuilds.
Step 3 — Start the app
docker compose up -d
[+] Running 2/2
✔ Volume "uptime-kuma_uptime-kuma-data" Created
✔ Container uptime-kuma Started
Step 4 — Confirm the container is running
docker compose ps
NAME IMAGE COMMAND SERVICE STATUS PORTS
uptime-kuma louislam/uptime-kuma:1.23.13 "/usr/bin/dumb-init …" uptime-kuma Up 3 seconds 127.0.0.1:3001->3001/tcp
Status must read Up. If it reads Restarting, check logs with docker compose logs --tail 50.
5. Put Nginx in Front as a Reverse Proxy
Install Nginx and write a minimal reverse-proxy config so the app is reachable on port 80 (or 443 once you add TLS).
Step 1 — Install Nginx
sudo apt-get install -y nginx
Step 2 — Write a server block
Replace your-domain.com with your actual domain or server IP.
sudo tee /etc/nginx/sites-available/uptime-kuma << 'EOF'
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
EOF
The Upgrade and Connection headers are required for Uptime Kuma's WebSocket connections. Include them for any app that uses WebSockets.
Step 3 — Enable the site and reload Nginx
sudo ln -s /etc/nginx/sites-available/uptime-kuma /etc/nginx/sites-enabled/uptime-kuma
sudo nginx -t
sudo systemctl reload nginx
nginx -t tests the config syntax before you reload; it will print syntax is ok and test is successful if everything is correct.
6. Verify It Works
Run these checks in order. Each one isolates a different layer.
Check 1 — Container is up
docker compose -f ~/apps/uptime-kuma/compose.yaml ps
Expected: STATUS column shows Up.
Check 2 — App responds locally
curl -sI http://127.0.0.1:3001
Expected: HTTP/1.1 200 OK (or a 301/302 redirect to the setup page).
Check 3 — Nginx proxies correctly
curl -sI http://your-domain.com
Expected: HTTP/1.1 200 OK with a Server: nginx header.
Check 4 — Container survives a reboot
sudo reboot
After SSH reconnects (~30 seconds), run:
docker compose -f ~/apps/uptime-kuma/compose.yaml ps
Expected: container is Up without any manual intervention. Docker's own daemon starts on boot via systemd (docker.service), and restart: unless-stopped handles the container.
7. Troubleshooting
Container status is Restarting immediately after docker compose up -d
Run docker compose logs --tail 100 from the project directory. Look for a port conflict (address already in use) or a missing environment variable. Fix the compose.yaml and run docker compose up -d again.
curl http://127.0.0.1:3001 returns Connection refused
The container hasn't finished starting or has crashed. Wait 10 seconds and retry. If it persists, check docker inspect uptime-kuma for the container's exit code.
nginx -t fails with unknown directive
You likely have a stray character from copy-paste. Open /etc/nginx/sites-available/uptime-kuma in nano and verify the file matches the block above exactly.
Nginx returns 502 Bad Gateway
The upstream app isn't listening on 127.0.0.1:3001. Verify the port binding with docker compose ps and confirm the ports line in compose.yaml reads "127.0.0.1:3001:3001".
Docker daemon fails to start after reboot
Run sudo systemctl status docker.service and sudo journalctl -u docker.service -n 50. A corrupt /var/lib/docker directory or a full disk (df -h) is the most common cause.
docker: permission denied even after usermod
You need a full logout/login, not just a new shell. Run groups and confirm docker appears in the list.
Next Steps
With Docker running and one self-hosted app deployed, the logical next moves are:
- Add TLS — Install
certbot(sudo apt-get install -y certbot python3-certbot-nginx) and runsudo certbot --nginx -d your-domain.comto get a free Let's Encrypt certificate. - Pin all image versions — Audit every
compose.yamlyou write and replace any:latesttags with a specific digest or semver tag. - Set up a Docker network — Replace
portswith a sharednetworksblock when multiple containers need to talk to each other without exposing ports to the host. - Automate image updates — Consider Watchtower with
--run-onceon a cron schedule if you want controlled, logged updates rather than manual pulls.
This Docker tutorial for self-hosted apps gives you a repeatable base. Every app you add follows the same pattern: a compose.yaml in its own directory, a named volume for persistence, a 127.0.0.1-bound port, and an Nginx server block in front.