Docker Tutorial for Self-Hosted Apps on Ubuntu 24.04

by David Park
Docker Tutorial for Self-Hosted Apps on Ubuntu 24.04

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:

  • image is pinned to 1.23.13 — never use latest in production; it makes rollbacks impossible.
  • ports binds only to 127.0.0.1 so the app is not exposed directly to the internet; Nginx will proxy it.
  • restart: unless-stopped means Docker restarts the container after a crash or reboot, but not if you explicitly stop it with docker compose stop.
  • The named volume uptime-kuma-data persists 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 run sudo certbot --nginx -d your-domain.com to get a free Let's Encrypt certificate.
  • Pin all image versions — Audit every compose.yaml you write and replace any :latest tags with a specific digest or semver tag.
  • Set up a Docker network — Replace ports with a shared networks block when multiple containers need to talk to each other without exposing ports to the host.
  • Automate image updates — Consider Watchtower with --run-once on 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.