Self-Hosting

Self-Host Your Developer Stack: Replace SaaS with Open Source on One Droplet

July 26, 202616 min read

Disclosure: Some links in this guide are affiliate links. If you sign up for DigitalOcean through them, ToolsHubKit earns a small commission — at no cost to you.

Running your own stack is the natural extension of ToolsHubKit's philosophy: your data, your compute, your rules. This guide shows you how to consolidate analytics, uptime monitoring, a password manager, file sync, and a notes app onto a single $12/mo Droplet — without losing the reliability you'd expect from SaaS.

We'll use Docker Compose as the management layer so each service is isolated, reproducible, and easy to update. Nginx handles routing to each subdomain. Everything gets automated backups.


1. The Case for Self-Hosting

SaaS you pay for Open-source replacement Monthly saving
Google Analytics Plausible / Umami ~$9–$19
Datadog (basic) Uptime Kuma ~$15–$25
1Password Teams Vaultwarden (Bitwarden) ~$5/seat
Dropbox Plus Nextcloud ~$11
Notion Outline ~$8–$10

A 2 GB Droplet at $12/mo comfortably runs all five simultaneously. That's a payback in under a month versus even modest SaaS subscriptions.


2. Provision the Droplet

Create a new Droplet at DigitalOcean. Get $200 in free credits for 60 days when you sign up.

Recommended specs for a multi-service box:

Services RAM vCPUs Droplet
2–3 lightweight services 1 GB 1 Basic $6/mo
4–6 services (this guide) 2 GB 2 Basic $12/mo
6+ services or Nextcloud 4 GB 2 Basic $24/mo

Choose Ubuntu 24.04 LTS x64, add your SSH key, select the datacenter region closest to your users.


3. Initial Server Setup

bash
ssh root@YOUR_SERVER_IP

# Create admin user
adduser admin
usermod -aG sudo admin

# Copy SSH keys
cp -r /root/.ssh /home/admin/
chown -R admin:admin /home/admin/.ssh

# Harden SSH
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart ssh

# Firewall
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw enable

Log out and re-login as admin.


4. Install Docker and Docker Compose

bash
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

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

# Run Docker without sudo
sudo usermod -aG docker $USER
newgrp docker

# Verify
docker --version && docker compose version

5. Directory Structure

code
/opt/selfhosted/
├── docker-compose.yml
├── .env
├── nginx/
│   ├── nginx.conf
│   └── conf.d/
│       ├── plausible.conf
│       ├── uptime-kuma.conf
│       ├── vaultwarden.conf
│       ├── nextcloud.conf
│       └── outline.conf
└── data/
    ├── plausible/
    ├── uptime-kuma/
    ├── vaultwarden/
    ├── nextcloud/
    └── outline/
bash
sudo mkdir -p /opt/selfhosted/{nginx/conf.d,data/{plausible,uptime-kuma,vaultwarden,nextcloud,outline}}
sudo chown -R admin:admin /opt/selfhosted
cd /opt/selfhosted

6. The Master docker-compose.yml

Create /opt/selfhosted/docker-compose.yml:

yaml
version: "3.9"

networks:
  proxy:
    external: true
  internal:
    internal: true

volumes:
  plausible-db:
  plausible-events:
  vaultwarden-data:
  nextcloud-data:
  nextcloud-db:
  outline-db:
  uptime-kuma-data:

services:

  # ── Plausible Analytics ──────────────────────────────
  plausible-db:
    image: postgres:16-alpine
    restart: always
    networks: [internal]
    volumes: [plausible-db:/var/lib/postgresql/data]
    environment:
      POSTGRES_DB: plausible
      POSTGRES_USER: plausible
      POSTGRES_PASSWORD: ${PLAUSIBLE_DB_PASS}

  plausible-events:
    image: clickhouse/clickhouse-server:23.8-alpine
    restart: always
    networks: [internal]
    volumes: [plausible-events:/var/lib/clickhouse]
    ulimits:
      nofile:
        soft: 262144
        hard: 262144

  plausible:
    image: ghcr.io/plausible/community-edition:v2
    restart: always
    networks: [proxy, internal]
    depends_on: [plausible-db, plausible-events]
    environment:
      BASE_URL: https://stats.${DOMAIN}
      SECRET_KEY_BASE: ${PLAUSIBLE_SECRET}
      DATABASE_URL: postgres://plausible:${PLAUSIBLE_DB_PASS}@plausible-db/plausible
      CLICKHOUSE_DATABASE_URL: http://plausible-events:8123/plausible_dev

  # ── Uptime Kuma ──────────────────────────────────────
  uptime-kuma:
    image: louislam/uptime-kuma:1
    restart: always
    networks: [proxy]
    volumes: [uptime-kuma-data:/app/data]

  # ── Vaultwarden (Bitwarden) ──────────────────────────
  vaultwarden:
    image: vaultwarden/server:latest
    restart: always
    networks: [proxy]
    volumes: [vaultwarden-data:/data]
    environment:
      DOMAIN: https://vault.${DOMAIN}
      SIGNUPS_ALLOWED: "false"
      ADMIN_TOKEN: ${VAULTWARDEN_ADMIN_TOKEN}

  # ── Nextcloud ────────────────────────────────────────
  nextcloud-db:
    image: mariadb:11
    restart: always
    networks: [internal]
    volumes: [nextcloud-db:/var/lib/mysql]
    environment:
      MYSQL_ROOT_PASSWORD: ${NEXTCLOUD_DB_ROOT_PASS}
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASS}

  nextcloud:
    image: nextcloud:28-apache
    restart: always
    networks: [proxy, internal]
    depends_on: [nextcloud-db]
    volumes: [nextcloud-data:/var/www/html]
    environment:
      MYSQL_HOST: nextcloud-db
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASS}
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASS}
      NEXTCLOUD_TRUSTED_DOMAINS: files.${DOMAIN}

  # ── Outline (Knowledge Base) ─────────────────────────
  outline-db:
    image: postgres:16-alpine
    restart: always
    networks: [internal]
    volumes: [outline-db:/var/lib/postgresql/data]
    environment:
      POSTGRES_DB: outline
      POSTGRES_USER: outline
      POSTGRES_PASSWORD: ${OUTLINE_DB_PASS}

  outline:
    image: outlinewiki/outline:latest
    restart: always
    networks: [proxy, internal]
    depends_on: [outline-db]
    environment:
      DATABASE_URL: postgres://outline:${OUTLINE_DB_PASS}@outline-db/outline
      SECRET_KEY: ${OUTLINE_SECRET}
      UTILS_SECRET: ${OUTLINE_UTILS_SECRET}
      URL: https://wiki.${DOMAIN}
      PORT: 3000

Create /opt/selfhosted/.env:

env
DOMAIN=yourdomain.com

PLAUSIBLE_DB_PASS=change_me_long_random
PLAUSIBLE_SECRET=change_me_64_char_random

VAULTWARDEN_ADMIN_TOKEN=change_me_bcrypt_hash

NEXTCLOUD_DB_ROOT_PASS=change_me
NEXTCLOUD_DB_PASS=change_me
NEXTCLOUD_ADMIN_PASS=change_me

OUTLINE_DB_PASS=change_me
OUTLINE_SECRET=change_me_32_char
OUTLINE_UTILS_SECRET=change_me_32_char

Generate secrets:

bash
# Random 64-char secret
openssl rand -hex 32

# Vaultwarden admin token (bcrypt hash of your chosen password)
docker run --rm -it vaultwarden/server /vaultwarden hash --preset owasp

7. Nginx as a Reverse Proxy

Install Nginx on the host (not in Docker — easier to manage TLS):

bash
sudo apt install -y nginx certbot python3-certbot-nginx

Create the shared Docker network:

bash
docker network create proxy

Create /opt/selfhosted/nginx/conf.d/plausible.conf (repeat the pattern for each service):

nginx
server {
    listen 80;
    server_name stats.yourdomain.com;

    location / {
        proxy_pass http://localhost:8000;
        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;
    }
}

Port mapping for each service (add ports: to docker-compose or use a proxy network):

Service Internal port Subdomain
Plausible 8000 stats.yourdomain.com
Uptime Kuma 3001 uptime.yourdomain.com
Vaultwarden 80 vault.yourdomain.com
Nextcloud 80 files.yourdomain.com
Outline 3000 wiki.yourdomain.com

Issue TLS for all subdomains at once:

bash
sudo certbot --nginx \
  -d stats.yourdomain.com \
  -d uptime.yourdomain.com \
  -d vault.yourdomain.com \
  -d files.yourdomain.com \
  -d wiki.yourdomain.com

8. Start Everything

bash
cd /opt/selfhosted
docker compose up -d
docker compose ps

Check logs for any startup errors:

bash
docker compose logs -f plausible
docker compose logs -f vaultwarden

9. DNS Setup

Add A records pointing each subdomain to your Droplet IP:

code
stats.yourdomain.com    A    YOUR_DROPLET_IP
uptime.yourdomain.com   A    YOUR_DROPLET_IP
vault.yourdomain.com    A    YOUR_DROPLET_IP
files.yourdomain.com    A    YOUR_DROPLET_IP
wiki.yourdomain.com     A    YOUR_DROPLET_IP

TTL of 300 seconds during setup, bump to 3600 once confirmed working.


10. Automated Backups

Back up all service data nightly to DigitalOcean Spaces (S3-compatible):

bash
sudo apt install -y s3cmd
s3cmd --configure  # enter your Spaces credentials

Create /opt/selfhosted/backup.sh:

bash
#!/bin/bash
set -e

BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/tmp/selfhosted-backup-$BACKUP_DATE
SPACES_BUCKET=s3://your-bucket/backups

mkdir -p $BACKUP_DIR

# Dump all Postgres databases
docker exec selfhosted-plausible-db-1 pg_dump -U plausible plausible > $BACKUP_DIR/plausible.sql
docker exec selfhosted-outline-db-1 pg_dump -U outline outline > $BACKUP_DIR/outline.sql

# Dump MariaDB (Nextcloud)
docker exec selfhosted-nextcloud-db-1 mysqldump -uroot -p$NEXTCLOUD_DB_ROOT_PASS nextcloud > $BACKUP_DIR/nextcloud.sql

# Tar and upload
tar -czf /tmp/backup-$BACKUP_DATE.tar.gz -C /tmp selfhosted-backup-$BACKUP_DATE
s3cmd put /tmp/backup-$BACKUP_DATE.tar.gz $SPACES_BUCKET/

# Clean up local files
rm -rf $BACKUP_DIR /tmp/backup-$BACKUP_DATE.tar.gz

# Keep only 14 days in Spaces
s3cmd ls $SPACES_BUCKET/ | awk '{print $4}' | sort | head -n -14 | xargs -r s3cmd del
bash
chmod +x /opt/selfhosted/backup.sh

# Run daily at 3am
echo "0 3 * * * admin /opt/selfhosted/backup.sh >> /var/log/selfhosted-backup.log 2>&1" | sudo tee /etc/cron.d/selfhosted-backup

11. Updates and Maintenance

Pull and rebuild all services with zero downtime:

bash
cd /opt/selfhosted

# Pull latest images
docker compose pull

# Recreate containers one by one (Compose handles this)
docker compose up -d --remove-orphans

# Clean up old images
docker image prune -f

Set a monthly calendar reminder to run this. Most of these projects follow semantic versioning — check their GitHub releases before jumping major versions.


12. Monitoring the Stack Itself

Uptime Kuma (already running at uptime.yourdomain.com) can monitor all your other services:

  1. Open Uptime Kuma, go to Add New Monitor
  2. Type: HTTP(s)
  3. Add monitors for: https://stats.yourdomain.com, https://vault.yourdomain.com, https://files.yourdomain.com, https://wiki.yourdomain.com
  4. Set notification to email or Telegram

You now have self-hosted uptime monitoring watching your self-hosted services.


13. Honest Limits — When SaaS Still Wins

Self-hosting isn't for everyone in every situation:

Scenario Verdict
Solo developer, privacy-focused ✅ Self-host wins
Small team, no DevOps person ⚠️ Managed SaaS easier
Regulated industry (HIPAA, PCI) ⚠️ Need compliance audit
99.99% uptime required ❌ Managed SaaS wins
You want to stop thinking about it ❌ Managed SaaS wins

A single Droplet has no automatic failover. If the server goes down, your services go down. For mission-critical workloads, use DigitalOcean's managed offerings or add a standby Droplet. For personal tools and small teams, a nightly backup + 5-minute restore is usually acceptable.


Related Guides


Resources