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.ymlfor 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-serverandimmich-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/dridevice 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
2222is for SSH Git operations. Map it to a non-standard port to avoid conflicting with your server’s SSH. - Set
ROOT_URLto 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.