How to Self-Host Anything with Docker Compose: From Single Containers to Full Stacks

Why Docker Compose Is the Unsung Hero of Self-Hosting

Here’s a scene you might recognize: you spun up a Docker container last Tuesday for a cute little self-hosted app — maybe it was Uptime Kuma, maybe it was a notes app — and it worked great. Then you rebooted the server. And it was gone. Not dead, just… absent. The container didn’t auto-start. Your neat little app is now a memory.

That’s the moment most people discover Docker Compose. And honestly? It changes everything.

Docker Compose lets you define your containers, their networks, their volumes, their restart policies — all in a single YAML file. No more remembering seventeen docker run flags. No more “did I map port 8080 or 8989?” moments. Just one file, one command, and your whole stack comes up.

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended) with Docker installed
  • Basic familiarity with terminal commands
  • A text editor you’re comfortable with (nano, vim, whatever)
  • About 20 minutes of uninterrupted time
  • Step 1: Make Sure Docker and Docker Compose Are Installed

    First, verify Docker is running:

    sudo systemctl status docker

    If it’s not running, start it and enable it on boot:

    sudo systemctl enable --now docker

    Now check if Docker Compose (v2 plugin) is available:

    docker compose version

    You should see something like Docker Compose version v2.x.x. If you get a “command not found,” install it:

    sudo apt update && sudo apt install -y docker-compose-plugin

    Step 2: Create Your Project Directory

    Good practice: keep each app stack in its own folder. This keeps things tidy and makes backups straightforward.

    mkdir -p ~/docker/myapp
    cd ~/docker/myapp

    Step 3: Write Your docker-compose.yml

    This is where the magic lives. Create the file:

    nano docker-compose.yml

    Let’s use a real example: Uptime Kuma — a beautiful, self-hosted uptime monitor. Here’s the full config:

    version: "3.8"
    
    services:
      uptime-kuma:
        image: louislam/uptime-kuma:1
        container_name: uptime-kuma
        ports:
          - "3001:3001"
        volumes:
          - ./data:/app/data
        restart: unless-stopped

    Let’s break down what each line does, because the YAML structure matters:

  • version: The Compose specification version. “3.8” is widely supported and modern enough for most needs.
  • services: The top-level key. Everything that follows is a container definition.
  • uptime-kuma: The service name. You can call this anything — it’s used for networking between containers and for docker compose commands.
  • image: The Docker image to pull. Using :1 pins to major version 1 — this prevents surprise breaking changes on update.
  • container_name: Gives the container a human-readable name (otherwise Docker assigns a random one like myapp-uptime-kuma-1).
  • ports: Maps HOST:CONTAINER. So port 3001 on your server forwards to 3001 inside the container.
  • volumes: Persists data to your host filesystem. Without this, your data dies when the container is removed. ./data:/app/data means “create a data/ folder next to this YAML file, mount it at /app/data inside the container.”
  • restart: unless-stopped means the container automatically restarts after crashes and on boot — unless you explicitly ran docker compose stop. This is the gold standard for self-hosted services.
  • Step 4: Bring It Up

    The -d flag runs everything in detached mode (background). Docker will pull the image if needed, create the container, and start it.

    docker compose up -d

    Open a browser to http://your-server-ip:3001 and you’ll see the setup screen. That’s it — your uptime monitor is running.

    Step 5: Essential Commands You’ll Actually Use

    Here are the commands you’ll reach for 90% of the time:

    # See what's running
    docker compose ps
    
    # View logs (live, like tail -f)
    docker compose logs -f
    
    # View logs for just one service
    docker compose logs -f uptime-kuma
    
    # Stop everything (data is safe!)
    docker compose stop
    
    # Start it back up
    docker compose start
    
    # Pull new images and restart (for updates)
    docker compose pull && docker compose up -d
    
    # Take everything down and remove containers
    docker compose down
    
    # Take everything down AND remove data volumes
    docker compose down -v

    Notice that last one — docker compose down -v. That’s the nuclear option. It removes containers and their persistent data. Use it carefully, especially on production.

    Multi-Service Stacks: Where Compose Really Shines

    A single container is fine, but the real power is orchestrating multiple services that talk to each other. Here’s a more realistic example: a monitoring stack with Uptime Kuma, Grafana, and Prometheus.

    version: "3.8"
    
    services:
      prometheus:
        image: prom/prometheus:latest
        container_name: prometheus
        volumes:
          - ./prometheus.yml:/etc/prometheus/prometheus.yml
          - prometheus_data:/prometheus
        ports:
          - "9090:9090"
        restart: unless-stopped
    
      grafana:
        image: grafana/grafana:latest
        container_name: grafana
        ports:
          - "3000:3000"
        volumes:
          - grafana_data:/var/lib/grafana
        environment:
          - GF_SECURITY_ADMIN_PASSWORD=changeme
        depends_on:
          - prometheus
        restart: unless-stopped
    
      uptime-kuma:
        image: louislam/uptime-kuma:1
        container_name: uptime-kuma
        ports:
          - "3001:3001"
        volumes:
          - uptime_data:/app/data
        restart: unless-stopped
    
    volumes:
      prometheus_data:
      grafana_data:
      uptime_data:

    Key things to notice here:

  • Named volumes (under the top-level volumes: key): These are managed by Docker and persist even if you run docker compose down. Better than bind mounts for production.
  • depends_on: Tells Compose to start Prometheus before Grafana. Grafana needs Prometheus running to show dashboards. This creates proper startup ordering.
  • environment: Pass environment variables into the container. Here we’re setting Grafana’s admin password so you don’t get locked out on first login.
  • Hidden networking: Because all three services are in the same Compose file, they automatically share a private network. Grafana can reach Prometheus at http://prometheus:9090 — no need to expose that port to your host! Only the services you ports: map are reachable from outside.
  • Common Pitfalls and Troubleshooting

    Port conflicts

    If you get port is already allocated, something else is using that port. Check with:

    sudo ss -tlnp | grep :3001

    Either stop the conflicting service or change the host port in your YAML (e.g., "3002:3001").

    Indentation matters

    YAML is whitespace-sensitive. Use spaces (not tabs), and be consistent. A single misplaced indentation level will give you cryptic errors like service "x" refers to undefined volume. Most editors have YAML syntax highlighting — turn it on.

    “Image not found” errors

    Usually means the image name or tag doesn’t exist on Docker Hub. Double-check the spelling. Tag :latest can be unpredictable — prefer pinning specific versions like :1.25.0 for anything important.

    Permission issues with volumes

    If a container can’t write to its volume, check the host directory permissions:

    ls -la ~/docker/myapp/data

    Docker containers run as root by default, so the mounted directory needs to be writable. Most well-documented images handle this gracefully — but it’s a common gotcha on custom setups.

    A Few Pro Tips

  • Use a .env file to store secrets and configuration outside your YAML. Then reference them as ${DB_PASSWORD} in your compose file. Never commit secrets to git.
  • Use docker compose (no hyphen) for Docker v2+. The old docker-compose (with hyphen) is deprecated.
  • Set resource limits on containers that might run wild: add deploy.resources.limits.memory: 512M under your service.
  • Use healthcheck directives so Docker knows when a container is actually ready (not just “the process started”).
  • Keep your Compose files in git. Commit them, version them. They’re your infrastructure-as-code.
  • Wrapping Up

    Docker Compose turns the chaos of managing individual containers into something declarative, repeatable, and shareable. One YAML file replaces pages of documentation. One command brings your whole stack up. And when you add restart: unless-stopped to everything? You get that beautiful feeling of setting something up and walking away, knowing it’ll still be there tomorrow.

    Start small. One app, one compose file, one volume. Then add another service. Then another. Before you know it, you have a little homelab kingdom — all defined in a few cleanly indented files.

    And hey — when you inevitably break something, docker compose down && docker compose up -d fixes most things in under ten seconds. That’s a kind of freedom.

    Leave a Reply

    Your email address will not be published. Required fields are marked *