I've run more than a dozen self-hosted services on my VPS. Early on, every deployment meant starting from scratch. Eventually I got tired of the repetition and built a set of reusable templates. Now, deploying a new service takes under 15 minutes from copying the template to having it running. Here's the complete process.
Why Docker Compose is the best approach for VPS self-hosting
Complete environment isolation means each service runs in its own container without interfering with anything else. Versioning is controlled—locking an image tag makes upgrades and rollbacks fully traceable. Migration is straightforward: compress the stack directory with its data volumes, decompress on the new server, done.
Paired with Nginx Proxy Manager or Traefik, domain binding and HTTPS certificate issuance become graphical operations—no manual Nginx configuration file editing required.
For running multiple self-hosted services long-term on a VPS, Docker Compose is the most cost-effective solution available.
Step 1: Install Docker
For Ubuntu/Debian, installing from the official Docker repository is the most stable approach:
# Remove old versions
sudo apt-get remove docker docker-engine docker.io containerd runc -y
# Add official repository
sudo apt update && sudo apt install -y ca-certificates curl gnupg
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
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
# Install Docker
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
Verify the installation:
docker --version
docker compose version
Add your user to the docker group to avoid needing sudo every time:
sudo usermod -aG docker $USER
newgrp docker
Step 2: Universal Docker Compose template
I keep all services under /opt/stacks/service-name/ for consistent management and easy backups.
docker-compose.yml (core template):
version: "3.8"
services:
main:
image: xxx/yyy:tag # Replace with the target software image
container_name: ${COMPOSE_PROJECT_NAME:-app}
restart: unless-stopped
ports:
- "${EXTERNAL_PORT:-8080}:80" # Left side is host port — plan ahead to avoid conflicts
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
# Add additional environment variables as required by the software
volumes:
- ./config:/config
- ./data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"] || exit 1
interval: 30s
timeout: 10s
retries: 3
networks:
default:
name: ${COMPOSE_PROJECT_NAME}_net
Companion .env file—manage all variables here rather than scattering them through the compose file:
EXTERNAL_PORT=5683
TZ=Asia/Shanghai
PUID=1000
PGID=1000
Most software runs with just three or four lines changed from this template. It saves a significant amount of repeated configuration work.
Step 3: Standardized deployment process
Follow this sequence every time you deploy a new service—it's less error-prone:
# 1. Create the directory and enter it
mkdir -p /opt/stacks/service-name && cd /opt/stacks/service-name
# 2. Copy the template or download the official compose file
# Adjust image, ports, and volumes as needed
# 3. Create the .env file with your configuration variables
# 4. Validate the compose file syntax (many people skip this — don't)
docker compose config
# 5. Start the service
docker compose up -d
# 6. Check startup logs to confirm everything is running correctly
docker compose logs -f
# 7. Open the relevant port in your cloud provider's security group and UFW
Step 4—docker compose config—is worth making a habit. It expands all template variables and displays the final configuration, making syntax errors and variable substitution problems immediately visible.
Step 4: Centralize domain management and SSL with Nginx Proxy Manager
Manually configuring Nginx and certificates is tedious. Nginx Proxy Manager (NPM) is the most low-maintenance solution available—a graphical interface that handles Let's Encrypt certificate issuance with a single click.
Deploy NPM:
version: '3.8'
services:
npm:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "81:81" # Admin panel — restrict access by IP in production
environment:
DB_MYSQL_HOST: db
DB_MYSQL_USER: npm
DB_MYSQL_PASSWORD: your_strong_password
DB_MYSQL_NAME: npm
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
depends_on:
- db
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: stronger_root_password
MYSQL_DATABASE: npm
MYSQL_USER: npm
MYSQL_PASSWORD: your_strong_password
volumes:
- ./mysql:/var/lib/mysql
docker compose up -d
Open http://server_IP:81. Default credentials are [email protected] / changeme—change these immediately after first login.
Adding a new service afterward takes seconds: create a Proxy Host record in NPM, enter the domain name and container's internal address, enable SSL auto-issuance, and it's done.
Common operations quick reference
# Pull latest image and restart (most common for routine upgrades)
docker compose pull && docker compose up -d
# Stream live logs
docker compose logs -f
# Restart service
docker compose restart
# Stop and remove containers (data directory preserved)
docker compose down
# Open a shell inside the container for debugging
docker compose exec main bash
# Back up the entire service stack
tar -czf backup-$(date +%F).tar.gz /opt/stacks/service-name/
Troubleshooting common issues
Port already in use: Run sudo lsof -i:port_number to identify the occupying process, then either terminate it or change EXTERNAL_PORT in your .env file.
Permission errors: Data directory permissions are the most common pitfall. chown -R 1000:1000 ./data ./config resolves this in most cases.
Can't access from external network: Check in this order—cloud provider security group rules, UFW firewall rules, and whether the container is actually running (docker compose ps).
Container restarting repeatedly: Check docker compose logs for the error. In 90% of cases it's an incorrectly configured environment variable or a database connection problem.
Self-hosted tools worth running on a VPS in 2026
All of these deploy cleanly with the template above:
- Photo management: Immich (the closest open-source equivalent to Google Photos)
- Password management: Vaultwarden (lightweight Bitwarden-compatible server, minimal resource usage)
- File management: Alist, Filebrowser
- Media server: Jellyfin
- Uptime monitoring: Uptime Kuma
- RSS reader: FreshRSS
- Knowledge base: BookStack, Outline
- Bookmark manager: Linkding
Each of these has an actively maintained official Docker image. A few lines changed from the template above is all it takes to get any of them running. Once you're comfortable with this workflow, your VPS effectively becomes a private cloud platform.