Docker Compose Made Easy: Deploy 10 Essential Self-Hosted Apps in Minutes


Docker Compose Made Easy: Deploy 10 Essential Self-Hosted Apps in Minutes

You’ve got a server. Maybe it’s a $180 Beelink mini PC, maybe it’s an old ThinkPad, maybe it’s a VPS you’re renting for $5/month. You want to run self-hosted apps on it, but you’re staring at installation docs that mention systemd services, PostgreSQL tuning, Nginx config blocks, and environment variables scattered across four different files.

Docker Compose makes all of that go away. One YAML file per app. One command to start it. One command to stop it. This guide gives you production-ready Compose configurations for 10 essential self-hosted apps that you can deploy in an afternoon.

TL;DR

  • Docker Compose defines your entire application stack in a single YAML file.
  • Every config below is tested and ready to use — just change the passwords and domain names.
  • Start with Vaultwarden (5 minutes), then Nextcloud, then add services as you need them.
  • Use a reverse proxy (Caddy or Traefik) to put everything behind HTTPS with real domain names.
  • Keep each app in its own directory with its own docker-compose.yml for clean management.

What Is Docker Compose (60-Second Version)

Docker runs applications in isolated containers. Docker Compose is a tool that reads a YAML file describing one or more containers — their images, configuration, storage, and networking — and brings them all up together.

Instead of running five docker run commands with 20 flags each, you write a docker-compose.yml file once and then:

docker compose up -d    # Start everything in the background
docker compose down     # Stop everything
docker compose logs -f  # Watch the logs
docker compose pull     # Pull updated images

That’s 90% of what you need to know. Let’s deploy some apps.

Before You Start

Make sure Docker and Docker Compose are installed:

# Install Docker (Ubuntu/Debian)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in, then verify:
docker compose version

Create a base directory for all your services:

mkdir -p ~/docker-services

Each app gets its own subdirectory with its own docker-compose.yml. This keeps things organized and lets you start/stop services independently.


1. Nextcloud — Your Private Cloud Storage

Replaces: Google Drive, Dropbox, OneDrive

Nextcloud handles file sync, calendars, contacts, collaborative editing, and dozens of plugins. It’s the Swiss Army knife of self-hosting.

# ~/docker-services/nextcloud/docker-compose.yml
version: "3.8"

services:
  nextcloud-db:
    image: mariadb:11
    container_name: nextcloud-db
    restart: unless-stopped
    volumes:
      - ./db-data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: change-this-root-password
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: change-this-db-password
    command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW

  nextcloud-redis:
    image: redis:7-alpine
    container_name: nextcloud-redis
    restart: unless-stopped

  nextcloud:
    image: nextcloud:29
    container_name: nextcloud
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./app-data:/var/www/html
      - ./user-data:/var/www/html/data
    environment:
      MYSQL_HOST: nextcloud-db
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: change-this-db-password
      REDIS_HOST: nextcloud-redis
      NEXTCLOUD_TRUSTED_DOMAINS: "nextcloud.yourdomain.com"
      OVERWRITEPROTOCOL: https
    depends_on:
      - nextcloud-db
      - nextcloud-redis

Key environment variables:

  • NEXTCLOUD_TRUSTED_DOMAINS — Set this to your domain or IP. Nextcloud rejects requests from unrecognized domains.
  • OVERWRITEPROTOCOL: https — Required if you’re behind a reverse proxy handling TLS.

First-time setup: Open http://your-server-ip:8080 in a browser. Create an admin account. Go to Settings > Overview and fix any warnings (they’ll suggest adding a cron job and phone region). Install the desktop sync client from nextcloud.com/install.

Pro tip: Mount user-data on a separate, larger drive if your system SSD is small.


2. Immich — Google Photos, But Yours

Replaces: Google Photos, iCloud Photos, Amazon Photos

Immich offers face recognition, location maps, memories, shared albums, and a mobile app that auto-backs up your camera roll. It’s genuinely impressive.

# ~/docker-services/immich/docker-compose.yml
version: "3.8"

services:
  immich-server:
    image: ghcr.io/immich-app/immich-server:v1.120.0
    container_name: immich-server
    ports:
      - "2283:2283"
    volumes:
      - ./upload:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    environment:
      DB_HOSTNAME: immich-db
      DB_USERNAME: postgres
      DB_PASSWORD: change-this-db-password
      DB_DATABASE_NAME: immich
      REDIS_HOSTNAME: immich-redis
    depends_on:
      - immich-db
      - immich-redis
    restart: unless-stopped

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v1.120.0
    container_name: immich-ml
    volumes:
      - ./model-cache:/cache
    restart: unless-stopped

  immich-redis:
    image: redis:7-alpine
    container_name: immich-redis
    restart: unless-stopped

  immich-db:
    image: tensorchord/pgvecto-rs:pg16-v0.3.0
    container_name: immich-db
    restart: unless-stopped
    volumes:
      - ./db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: change-this-db-password
      POSTGRES_USER: postgres
      POSTGRES_DB: immich
      POSTGRES_INITDB_ARGS: "--data-checksums"

Key environment variables:

  • Database credentials must match between immich-server and immich-db.
  • The ML service doesn’t need explicit config — it auto-discovers the server.

First-time setup: Open http://your-server-ip:2283. Create an admin account. Install the Immich app on iOS/Android and point it to your server. Enable auto-backup in the app settings. Initial photo processing takes time — a library of 10,000 photos might take 6-12 hours on an N100, or 2-3 hours on a Ryzen 5.

Storage planning: Budget ~15-20MB per photo (original + thumbnails + ML embeddings). A 50,000 photo library needs roughly 750GB-1TB.


3. Jellyfin — Stream Your Media

Replaces: Plex, Emby

No accounts, no telemetry, no premium tier. Jellyfin just works.

# ~/docker-services/jellyfin/docker-compose.yml
version: "3.8"

services:
  jellyfin:
    image: jellyfin/jellyfin:10.10.3
    container_name: jellyfin
    restart: unless-stopped
    ports:
      - "8096:8096"
    volumes:
      - ./config:/config
      - ./cache:/cache
      - /path/to/your/movies:/data/movies:ro
      - /path/to/your/tvshows:/data/tvshows:ro
      - /path/to/your/music:/data/music:ro
    environment:
      - JELLYFIN_PublishedServerUrl=https://jellyfin.yourdomain.com
    # Enable Intel Quick Sync hardware transcoding:
    devices:
      - /dev/dri:/dev/dri

Key configuration:

  • Map your media directories as read-only (:ro) volumes.
  • The /dev/dri device passthrough enables Intel Quick Sync hardware transcoding, which dramatically reduces CPU usage.

First-time setup: Open http://your-server-ip:8096. Walk through the wizard. Add your media libraries (point them to /data/movies, /data/tvshows, etc.). Enable hardware transcoding under Dashboard > Playback > Transcoding > Hardware acceleration: Video Acceleration API (VAAPI).


4. Vaultwarden — Password Management Done Right

Replaces: Bitwarden (cloud), 1Password, LastPass

The easiest service on this list to deploy. Uses 30MB of RAM. Compatible with all official Bitwarden clients.

# ~/docker-services/vaultwarden/docker-compose.yml
version: "3.8"

services:
  vaultwarden:
    image: vaultwarden/server:1.32.5
    container_name: vaultwarden
    restart: unless-stopped
    ports:
      - "8081:80"
    volumes:
      - ./data:/data
    environment:
      DOMAIN: "https://vault.yourdomain.com"
      SIGNUPS_ALLOWED: "false"
      SMTP_HOST: "smtp.yourmailprovider.com"
      SMTP_FROM: "vault@yourdomain.com"
      SMTP_PORT: 587
      SMTP_SECURITY: starttls
      SMTP_USERNAME: "vault@yourdomain.com"
      SMTP_PASSWORD: "your-smtp-password"

Key environment variables:

  • SIGNUPS_ALLOWED: "false" — Set this AFTER creating your account. You don’t want strangers registering on your instance.
  • DOMAIN — Must match your actual access URL. Needed for WebAuthn/FIDO2 and email links.
  • SMTP settings — Required for email verification, password reset, and two-factor recovery.

First-time setup: Open http://your-server-ip:8081, create your account, then immediately set SIGNUPS_ALLOWED to false and restart the container. Install the Bitwarden browser extension and mobile app, and point them to your custom server URL.

Critical: Back up the ./data directory regularly. Losing this means losing all your passwords.


5. Uptime Kuma — Know When Things Break

Replaces: UptimeRobot, Pingdom, StatusCake

A beautiful monitoring dashboard that checks your services and sends alerts when something goes down.

# ~/docker-services/uptime-kuma/docker-compose.yml
version: "3.8"

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

That’s it. Seriously.

First-time setup: Open http://your-server-ip:3001. Create an admin account. Add monitors for each of your services — HTTP checks, TCP port checks, Docker container checks, even DNS checks. Set up notifications via Telegram, Discord, email, or Slack.

Pro tip: Add monitors for external services too (your ISP’s DNS, your domain registrar, Cloudflare’s status). When your Nextcloud goes down, it helps to immediately know whether it’s your server or your internet connection.


6. Pi-hole — Block Ads at the Network Level

Replaces: Browser ad blockers (for devices that don’t support them)

Pi-hole acts as your network’s DNS server and silently drops requests to known ad/tracker domains.

# ~/docker-services/pihole/docker-compose.yml
version: "3.8"

services:
  pihole:
    image: pihole/pihole:2024.07.0
    container_name: pihole
    restart: unless-stopped
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8082:80/tcp"
    volumes:
      - ./etc-pihole:/etc/pihole
      - ./etc-dnsmasq:/etc/dnsmasq.d
    environment:
      TZ: "America/New_York"
      WEBPASSWORD: "change-this-admin-password"
      FTLCONF_LOCAL_IPV4: "192.168.1.100"
    cap_add:
      - NET_ADMIN

Key environment variables:

  • FTLCONF_LOCAL_IPV4 — Set this to your server’s local IP address.
  • WEBPASSWORD — The admin dashboard password.
  • TZ — Your timezone, for accurate logging.

First-time setup: Open http://your-server-ip:8082/admin. Then change your router’s DNS server setting to point to your server’s IP address (e.g., 192.168.1.100). Every device on your network will now use Pi-hole for DNS.

Common issue: If you’re already running a DNS server or systemd-resolved on port 53, you’ll get a port conflict. On Ubuntu, disable the stub resolver:

sudo sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved

7. Paperless-ngx — Digitize Your Documents

Replaces: Filing cabinets, Evernote (for document storage), paid document management

Scan or import documents, and Paperless-ngx automatically OCRs them, tags them, and makes them full-text searchable. Tax returns, receipts, manuals, medical records — all searchable in seconds.

# ~/docker-services/paperless/docker-compose.yml
version: "3.8"

services:
  paperless-redis:
    image: redis:7-alpine
    container_name: paperless-redis
    restart: unless-stopped

  paperless-db:
    image: postgres:16-alpine
    container_name: paperless-db
    restart: unless-stopped
    volumes:
      - ./db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: change-this-db-password

  paperless:
    image: ghcr.io/paperless-ngx/paperless-ngx:2.13
    container_name: paperless
    restart: unless-stopped
    ports:
      - "8083:8000"
    volumes:
      - ./data:/usr/src/paperless/data
      - ./media:/usr/src/paperless/media
      - ./export:/usr/src/paperless/export
      - ./consume:/usr/src/paperless/consume
    environment:
      PAPERLESS_REDIS: redis://paperless-redis:6379
      PAPERLESS_DBHOST: paperless-db
      PAPERLESS_DBPASS: change-this-db-password
      PAPERLESS_OCR_LANGUAGE: eng
      PAPERLESS_SECRET_KEY: generate-a-long-random-string-here
      PAPERLESS_URL: https://paperless.yourdomain.com
      PAPERLESS_TIME_ZONE: America/New_York
    depends_on:
      - paperless-db
      - paperless-redis

First-time setup: Create your admin user:

cd ~/docker-services/paperless
docker compose exec paperless python3 manage.py createsuperuser

Drop PDF files into the ./consume directory and Paperless will automatically import, OCR, and classify them. You can also email documents to it or use the web uploader.


8. Gitea — Self-Hosted Git

Replaces: GitHub (for private repos), GitLab (lighter weight)

If you want private repositories without paying GitHub $4/month/user, or you just want full control over your code hosting, Gitea is remarkably lightweight and fast.

# ~/docker-services/gitea/docker-compose.yml
version: "3.8"

services:
  gitea-db:
    image: postgres:16-alpine
    container_name: gitea-db
    restart: unless-stopped
    volumes:
      - ./db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: gitea
      POSTGRES_PASSWORD: change-this-db-password
      POSTGRES_DB: gitea

  gitea:
    image: gitea/gitea:1.22
    container_name: gitea
    restart: unless-stopped
    ports:
      - "3000:3000"
      - "2222:22"
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    environment:
      USER_UID: 1000
      USER_GID: 1000
      GITEA__database__DB_TYPE: postgres
      GITEA__database__HOST: gitea-db:5432
      GITEA__database__NAME: gitea
      GITEA__database__USER: gitea
      GITEA__database__PASSWD: change-this-db-password
      GITEA__server__ROOT_URL: https://git.yourdomain.com
      GITEA__server__SSH_DOMAIN: git.yourdomain.com
      GITEA__server__SSH_PORT: 2222
    depends_on:
      - gitea-db

Key configuration:

  • Port 2222 is for SSH Git operations. Map it to a non-standard port to avoid conflicting with your server’s SSH.
  • Set ROOT_URL to your actual domain — Gitea uses this to generate clone URLs.

First-time setup: Open http://your-server-ip:3000. Walk through the installation wizard. The database settings are pre-filled from the environment variables. Create your admin account on the next screen.


9. Plausible Analytics — Privacy-Friendly Website Analytics

Replaces: Google Analytics

Cookie-free, GDPR-compliant by default, and the dashboard loads in under a second. The tracking script is under 1KB.

# ~/docker-services/plausible/docker-compose.yml
version: "3.8"

services:
  plausible-db:
    image: postgres:16-alpine
    container_name: plausible-db
    restart: unless-stopped
    volumes:
      - ./db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: change-this-db-password

  plausible-events-db:
    image: clickhouse/clickhouse-server:24.3-alpine
    container_name: plausible-events-db
    restart: unless-stopped
    volumes:
      - ./event-data:/var/lib/clickhouse
    ulimits:
      nofile:
        soft: 262144
        hard: 262144

  plausible:
    image: ghcr.io/plausible/community-edition:v2.1
    container_name: plausible
    restart: unless-stopped
    ports:
      - "8084:8000"
    environment:
      DATABASE_URL: postgres://postgres:change-this-db-password@plausible-db:5432/plausible_db
      CLICKHOUSE_DATABASE_URL: http://plausible-events-db:8123/plausible_events_db
      BASE_URL: https://analytics.yourdomain.com
      SECRET_KEY_BASE: "generate-a-64-char-random-string-openssl-rand-hex-32"
      TOTP_VAULT_KEY: "generate-another-random-string-openssl-rand-base64-32"
      MAILER_EMAIL: analytics@yourdomain.com
      SMTP_HOST_ADDR: smtp.yourmailprovider.com
      SMTP_HOST_PORT: 587
      SMTP_USER_NAME: your-smtp-user
      SMTP_USER_PWD: your-smtp-password
      SMTP_HOST_SSL_ENABLED: "false"
      DISABLE_REGISTRATION: invite_only
    depends_on:
      - plausible-db
      - plausible-events-db

Generating the required secrets:

# SECRET_KEY_BASE
openssl rand -hex 32

# TOTP_VAULT_KEY
openssl rand -base64 32

First-time setup: Create your admin account:

cd ~/docker-services/plausible
docker compose exec plausible sh -c "bin/plausible createuser --email you@email.com --password yourpassword --name 'Your Name'"

Then add the tracking snippet to your websites. It’s a single <script> tag.


10. n8n — Workflow Automation

Replaces: Zapier, Make (Integromat), IFTTT

n8n is a visual workflow automation tool. Connect APIs, trigger actions on schedules or webhooks, transform data between services. The self-hosted version has no workflow or execution limits.

# ~/docker-services/n8n/docker-compose.yml
version: "3.8"

services:
  n8n-db:
    image: postgres:16-alpine
    container_name: n8n-db
    restart: unless-stopped
    volumes:
      - ./db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: change-this-db-password
      POSTGRES_DB: n8n

  n8n:
    image: n8nio/n8n:1.70
    container_name: n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    volumes:
      - ./data:/home/node/.n8n
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: n8n-db
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: change-this-db-password
      N8N_HOST: n8n.yourdomain.com
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://n8n.yourdomain.com/
      GENERIC_TIMEZONE: America/New_York
    depends_on:
      - n8n-db

First-time setup: Open http://your-server-ip:5678. Create an owner account. Start building workflows — the visual editor is intuitive, and the community has shared thousands of workflow templates.

Use case ideas: Auto-backup your Gitea repos to another server. Send a daily digest of new Paperless documents. Sync Nextcloud calendar events to a Telegram channel. The possibilities are wide open.


Managing Multiple Compose Files

After deploying several apps, your directory structure should look like this:

~/docker-services/
├── nextcloud/
│   └── docker-compose.yml
├── immich/
│   └── docker-compose.yml
├── jellyfin/
│   └── docker-compose.yml
├── vaultwarden/
│   └── docker-compose.yml
├── uptime-kuma/
│   └── docker-compose.yml
├── pihole/
│   └── docker-compose.yml
├── paperless/
│   └── docker-compose.yml
├── gitea/
│   └── docker-compose.yml
├── plausible/
│   └── docker-compose.yml
└── n8n/
    └── docker-compose.yml

Useful Commands

# Start a specific service
cd ~/docker-services/nextcloud && docker compose up -d

# View logs for a service
cd ~/docker-services/immich && docker compose logs -f

# Update a service
cd ~/docker-services/jellyfin && docker compose pull && docker compose up -d

# Stop everything at once (useful for server maintenance)
for dir in ~/docker-services/*/; do
  (cd "$dir" && docker compose down)
done

# Start everything at once
for dir in ~/docker-services/*/; do
  (cd "$dir" && docker compose up -d)
done

A Quick Script to Update All Services

#!/bin/bash
# ~/docker-services/update-all.sh
for dir in ~/docker-services/*/; do
  service=$(basename "$dir")
  echo "Updating $service..."
  (cd "$dir" && docker compose pull -q && docker compose up -d)
done
echo "All services updated."

Before running this on critical services like Vaultwarden, read the changelogs for breaking changes. Automated updates are convenient but risky for services where data integrity matters.

Monitoring Your Docker Services

Running 10 services means 10 things that can silently fail. A database can run out of disk space at 3 AM. A container can crash and not restart properly. An SSL certificate can expire.

Uptime Kuma (app #5 above) handles basic “is it up?” monitoring. But for deeper insights — are your scheduled backup scripts running, are your cron jobs completing on time, are container health checks passing — you’ll want dedicated monitoring.

A good cron monitoring setup alerts you not just when a job fails, but when a job that was supposed to run doesn’t run at all. This is especially critical for backup scripts and database maintenance tasks where silent failures can go unnoticed for weeks until you actually need that backup and discover it hasn’t run since October.

Adding a Reverse Proxy

All these services are running on different ports. A reverse proxy gives them proper domain names and handles TLS certificates. Here’s a minimal Caddy setup:

# ~/docker-services/caddy/docker-compose.yml
version: "3.8"

services:
  caddy:
    image: caddy:2
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./caddy-data:/data
      - ./caddy-config:/config
    network_mode: host  # Simplest way to reach other containers' published ports
# ~/docker-services/caddy/Caddyfile

nextcloud.yourdomain.com {
    reverse_proxy localhost:8080
}

photos.yourdomain.com {
    reverse_proxy localhost:2283
}

jellyfin.yourdomain.com {
    reverse_proxy localhost:8096
}

vault.yourdomain.com {
    reverse_proxy localhost:8081
}

status.yourdomain.com {
    reverse_proxy localhost:3001
}

paperless.yourdomain.com {
    reverse_proxy localhost:8083
}

git.yourdomain.com {
    reverse_proxy localhost:3000
}

analytics.yourdomain.com {
    reverse_proxy localhost:8084
}

n8n.yourdomain.com {
    reverse_proxy localhost:5678
}

Caddy automatically provisions Let’s Encrypt certificates for every domain. No configuration needed beyond what’s above.

For more context on networking options, reverse proxies, and secure remote access, see our complete self-hosting guide.

Wrapping Up

Ten apps, ten Compose files, one afternoon. You’ve now got a private cloud that replaces thousands of dollars in annual SaaS subscriptions with software that runs on hardware you own.

The beauty of Docker Compose is how modular it is. Don’t need Gitea? Skip it. Want to add something later? Create a new directory, drop in a docker-compose.yml, and run docker compose up -d. Your other services aren’t affected.

Start with the apps that replace services you’re actively paying for — that’s where the immediate value is. Then expand as your comfort level grows.

If you’re just getting started with self-hosting and want to understand the bigger picture — hardware selection, networking, security, cost analysis — our Complete Guide to Self-Hosting in 2026 covers everything you need to know. And if you’re a developer looking to replace more of your commercial toolchain, check out our Bruno vs Postman comparison for the API testing space.


Found an issue with any of these configs? Running into a problem we didn’t cover? Let us know and we’ll update the guide.