The Selfhosted Stack: Replace 12 SaaS Subscriptions with Your Own Server

The SaaS Tax
An average startup spends $380/month per employee on SaaS tools. A 10-person team burns $45,000/year on subscriptions that hold their data, send their email, sync their passwords, and store their files. Most of these tools are thin wrappers around open source software running on someone else's server.
For the price of a single bare metal server ($99/month on ServerGurus), you can self-host every one of them - retaining full control over your data, zero per-seat pricing, and an uptime SLA that depends on your skill, not a vendor's status page.
This guide builds the selfhosted stack - a complete replacement for 12 SaaS products on a single server. Docker Compose, one YAML file, everything behind a reverse proxy with automatic SSL.
The Stack at a Glance
| SaaS You Pay For | Selfhosted Replacement | Monthly Saving |
|---|---|---|
| Google Workspace | Stalwart Mail + SnappyMail | $6/user |
| Notion / Confluence | Outline Wiki | $10/user |
| Slack / Teams | Mattermost | $8/user |
| GitHub / GitLab | Gitea | $4/user |
| 1Password / Bitwarden | Vaultwarden | $4/user |
| Google Drive / Dropbox | Nextcloud | $10/user |
| Vercel / Netlify | Coolify | $20/project |
| Sentry / Datadog | Sentry + Uptime Kuma | $15/host |
| Google Analytics | Plausible | $9/site |
| Mailchimp | Listmonk | $30/mo |
| Auth0 / Clerk | Authentik | $0.02/user |
| Slack Workflows | n8n | $20/user |
Total SaaS cost for 10 people: ~$3,800/month. Selfhosted stack: one $99 server. The math is not subtle.
Hardware Requirement
A dedicated server or VPS with:
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 4 cores | 8 cores (AMD EPYC / Intel Xeon) |
| RAM | 8GB | 16GB+ (ZFS ARC loves RAM) |
| Storage | 100GB SSD | 2 × 500GB NVMe (mirror) |
| Network | 1Gbps | 1Gbps with 5TB transfer |
ServerGurus' Entry VPS ($19.99/month) runs the light stack. Our Bare Metal E-2488 ($99/month) runs everything comfortably with room to grow.
Step 1: Base System (Ubuntu 24.04)
# After provisioning your server
apt update && apt upgrade -y
apt install -y docker.io docker-compose-v2 nginx certbot python3-certbot-nginx ufw
# Enable firewall
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Create directory structure
mkdir -p /opt/selfhosted/{mail,gitea,nextcloud,vaultwarden,mattermost,coolify,outline,sentry,plausible,listmonk,n8n,authentik,uptime}
mkdir -p /opt/selfhosted/data/{postgres,redis,backups}
Step 2: Reverse Proxy with Automatic SSL
# Caddy is the simplest reverse proxy for selfhosted stacks
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main" | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy
# Caddyfile
cat > /etc/caddy/Caddyfile << 'EOF'
{
email [email protected]
}
git.yourdomain.com {
reverse_proxy localhost:3001
}
cloud.yourdomain.com {
reverse_proxy localhost:8080
}
vault.yourdomain.com {
reverse_proxy localhost:8081
}
chat.yourdomain.com {
reverse_proxy localhost:8065
}
wiki.yourdomain.com {
reverse_proxy localhost:3002
}
deploy.yourdomain.com {
reverse_proxy localhost:8000
}
errors.yourdomain.com {
reverse_proxy localhost:9000
}
analytics.yourdomain.com {
reverse_proxy localhost:8001
}
newsletter.yourdomain.com {
reverse_proxy localhost:9001
}
automation.yourdomain.com {
reverse_proxy localhost:5678
}
auth.yourdomain.com {
reverse_proxy localhost:9002
}
status.yourdomain.com {
reverse_proxy localhost:3003
}
EOF
systemctl restart caddy
Caddy automatically obtains and renews Let's Encrypt certificates for every subdomain. No cron jobs, no certbot renewal scripts. Point your DNS A record at the server's IP, wildcard if you prefer:
@ A 165.140.164.100
* CNAME @
Step 3: Docker Compose - The Whole Stack
# /opt/selfhosted/docker-compose.yml
version: '3.8'
services:
# ── DATABASES ──────────────────────────────────
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./data/postgres:/var/lib/postgresql/data
networks:
- backend
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- ./data/redis:/data
networks:
- backend
# ── GITEA (GitHub replacement) ──────────────────
gitea:
image: gitea/gitea:1.23
restart: unless-stopped
ports:
- '127.0.0.1:3001:3000'
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=postgres:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${DB_PASSWORD}
volumes:
- ./gitea/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
- postgres
networks:
- backend
# ── NEXTCLOUD (Google Drive replacement) ────────
nextcloud:
image: nextcloud:30-fpm-alpine
restart: unless-stopped
volumes:
- ./nextcloud/html:/var/www/html
- ./nextcloud/data:/var/www/html/data
- ./nextcloud/config:/var/www/html/config
environment:
- POSTGRES_HOST=postgres
- POSTGRES_DB=nextcloud
- POSTGRES_USER=nextcloud
- POSTGRES_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
depends_on:
- postgres
- redis
networks:
- backend
nextcloud-web:
image: nginx:alpine
restart: unless-stopped
ports:
- '127.0.0.1:8080:80'
volumes:
- ./nextcloud/web.conf:/etc/nginx/conf.d/default.conf
- ./nextcloud/html:/var/www/html
depends_on:
- nextcloud
networks:
- backend
# ── VAULTWARDEN (1Password replacement) ─────────
vaultwarden:
image: vaultwarden/server:latest
restart: unless-stopped
ports:
- '127.0.0.1:8081:80'
environment:
- DOMAIN=https://vault.yourdomain.com
- SIGNUPS_ALLOWED=false
- WEBSOCKET_ENABLED=true
volumes:
- ./vaultwarden/data:/data
networks:
- backend
# ── MATTERMOST (Slack replacement) ──────────────
mattermost:
image: mattermost/mattermost-team-edition:10.5
restart: unless-stopped
ports:
- '127.0.0.1:8065:8065'
environment:
- MM_SQLSETTINGS_DRIVERNAME=postgres
- MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASSWORD}@postgres:5432/mattermost?sslmode=disable
- MM_SERVICESETTINGS_SITEURL=https://chat.yourdomain.com
volumes:
- ./mattermost/config:/mattermost/config
- ./mattermost/data:/mattermost/data
- ./mattermost/plugins:/mattermost/plugins
depends_on:
- postgres
networks:
- backend
# ── OUTLINE (Notion replacement) ────────────────
outline:
image: outlinewiki/outline:latest
restart: unless-stopped
ports:
- '127.0.0.1:3002:3000'
environment:
- DATABASE_URL=postgres://outline:${DB_PASSWORD}@postgres:5432/outline
- REDIS_URL=redis://redis:6379
- URL=https://wiki.yourdomain.com
- SECRET_KEY=${OUTLINE_SECRET}
- UTILS_SECRET=${OUTLINE_UTILS_SECRET}
- OIDC_CLIENT_ID=outline
- OIDC_CLIENT_SECRET=${OUTLINE_OIDC_SECRET}
- OIDC_AUTH_URI=https://auth.yourdomain.com/application/o/authorize/
- OIDC_TOKEN_URI=https://auth.yourdomain.com/application/o/token/
- OIDC_USERINFO_URI=https://auth.yourdomain.com/application/o/userinfo/
volumes:
- ./outline/data:/var/lib/outline/data
depends_on:
- postgres
- redis
networks:
- backend
# ── COOLIFY (Vercel/Netlify replacement) ────────
coolify:
image: coollabsio/coolify:latest
restart: unless-stopped
ports:
- '127.0.0.1:8000:3000'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./coolify/data:/app/data
environment:
- APP_ID=coolify
- APP_ENV=production
networks:
- backend
# ── SENTRY (Error tracking) ────────────────────
sentry:
image: getsentry/sentry:24.12
restart: unless-stopped
ports:
- '127.0.0.1:9000:9000'
environment:
- SENTRY_SECRET_KEY=${SENTRY_SECRET_KEY}
- SENTRY_POSTGRES_HOST=postgres
- SENTRY_DB_USER=sentry
- SENTRY_DB_PASSWORD=${DB_PASSWORD}
- SENTRY_REDIS_HOST=redis
volumes:
- ./sentry/data:/data
depends_on:
- postgres
- redis
networks:
- backend
# ── PLAUSIBLE (Google Analytics replacement) ────
plausible:
image: plausible/analytics:v3
restart: unless-stopped
ports:
- '127.0.0.1:8001:8000'
environment:
- BASE_URL=https://analytics.yourdomain.com
- SECRET_KEY_BASE=${PLAUSIBLE_SECRET}
- DATABASE_URL=postgres://plausible:${DB_PASSWORD}@postgres:5432/plausible
depends_on:
- postgres
networks:
- backend
# ── LISTMONK (Mailchimp replacement) ────────────
listmonk:
image: listmonk/listmonk:v4
restart: unless-stopped
ports:
- '127.0.0.1:9001:9000'
environment:
- LISTMONK_db__host=postgres
- LISTMONK_db__user=listmonk
- LISTMONK_db__password=${DB_PASSWORD}
- LISTMONK_db__database=listmonk
volumes:
- ./listmonk/config.toml:/listmonk/config.toml
depends_on:
- postgres
networks:
- backend
# ── N8N (Zapier/Make replacement) ───────────────
n8n:
image: n8nio/n8n:latest
restart: unless-stopped
ports:
- '127.0.0.1:5678:5678'
environment:
- N8N_HOST=automation.yourdomain.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${DB_PASSWORD}
volumes:
- ./n8n/data:/home/node/.n8n
depends_on:
- postgres
networks:
- backend
# ── AUTHENTIK (Auth0/Clerk replacement) ─────────
authentik-server:
image: ghcr.io/goauthentik/server:2024.12
restart: unless-stopped
ports:
- '127.0.0.1:9002:9000'
- '127.0.0.1:9443:9443'
environment:
- AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET}
- AUTHENTIK_POSTGRESQL__HOST=postgres
- AUTHENTIK_POSTGRESQL__NAME=authentik
- AUTHENTIK_POSTGRESQL__USER=authentik
- AUTHENTIK_POSTGRESQL__PASSWORD=${DB_PASSWORD}
- AUTHENTIK_REDIS__HOST=redis
volumes:
- ./authentik/media:/media
- ./authentik/custom-templates:/templates
depends_on:
- postgres
- redis
networks:
- backend
authentik-worker:
image: ghcr.io/goauthentik/server:2024.12
restart: unless-stopped
command: worker
environment:
- AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET}
- AUTHENTIK_POSTGRESQL__HOST=postgres
- AUTHENTIK_POSTGRESQL__NAME=authentik
- AUTHENTIK_POSTGRESQL__USER=authentik
- AUTHENTIK_POSTGRESQL__PASSWORD=${DB_PASSWORD}
- AUTHENTIK_REDIS__HOST=redis
volumes:
- ./authentik/media:/media
- ./authentik/custom-templates:/templates
depends_on:
- postgres
- redis
networks:
- backend
# ── UPTIME KUMA (Monitoring) ────────────────────
uptime-kuma:
image: louislam/uptime-kuma:1
restart: unless-stopped
ports:
- '127.0.0.1:3003:3001'
volumes:
- ./uptime/data:/app/data
networks:
- backend
networks:
backend:
driver: bridge
Step 4: Email - The Hard Part
Email self-hosting has a bad reputation for good reason: deliverability is hard. Stalwart Mail solves this.
# Stalwart Mail - modern, all-in-one mail server with JMAP, IMAP, SMTP
mkdir /opt/selfhosted/stalwart
docker run -d --name stalwart-mail \
-p 25:25 -p 143:143 -p 993:993 -p 587:587 -p 465:465 -p 4190:4190 \
-v /opt/selfhosted/stalwart:/opt/stalwart-mail \
stalwartlabs/mail-server:latest
Stalwart handles SPF, DKIM, DMARC, and TLS automatically. The critical deliverability steps:
- PTR record: Your server IP must reverse-resolve to your mail domain
- SPF:
v=spf1 mx -all(only your server sends mail) - DKIM: Stalwart generates the key; add the public key to DNS
- DMARC:
v=DMARC1; p=quarantine; rua=mailto:[email protected] - Warm-up: Send to yourself first, then to a few friends, then to your list - never blast 10,000 emails from a new IP
For webmail, SnappyMail is lightweight and fast:
docker run -d --name snappymail \
-p 127.0.0.1:8082:8888 \
-v /opt/selfhosted/snappymail:/var/lib/snappymail \
djmaze/snappymail:latest
Add to Caddyfile:
mail.yourdomain.com {
reverse_proxy localhost:8082
}
Step 5: Backups - Non-Negotiable
You are now responsible for your data. Automated, off-site, encrypted backups are mandatory:
# /opt/selfhosted/backup.sh
#!/bin/bash
set -e
BACKUP_DIR="/opt/selfhosted/data/backups/$(date +%Y-%m-%d_%H-%M)"
mkdir -p "$BACKUP_DIR"
# Stop services briefly for consistent dumps
docker compose -f /opt/selfhosted/docker-compose.yml stop
# Dump PostgreSQL
docker compose -f /opt/selfhosted/docker-compose.yml run --rm postgres \
pg_dumpall -U postgres > "$BACKUP_DIR/postgres-all.sql"
# Copy persistent volumes
rsync -a /opt/selfhosted/gitea/data "$BACKUP_DIR/gitea"
rsync -a /opt/selfhosted/nextcloud/data "$BACKUP_DIR/nextcloud"
rsync -a /opt/selfhosted/vaultwarden/data "$BACKUP_DIR/vaultwarden"
# Restart
docker compose -f /opt/selfhosted/docker-compose.yml up -d
# Encrypt and ship to remote storage
tar czf - "$BACKUP_DIR" | gpg --encrypt --recipient [email protected] \
| rclone rcat remote:backups/selfhosted/$(date +%Y-%m-%d).tar.gz.gpg
# Keep 7 days locally
find /opt/selfhosted/data/backups -maxdepth 1 -mtime +7 -exec rm -rf {} \;
Schedule with cron:
# Daily at 3 AM
echo '0 3 * * * root /opt/selfhosted/backup.sh >> /var/log/backup.log 2>&1' > /etc/cron.d/selfhosted-backup
For remote storage, rclone supports S3-compatible, Backblaze B2, and rsync.net. ServerGurus includes S3-compatible object storage with every bare metal plan.
Step 6: Unified Auth with Authentik
Once Authentik is running, every application authenticates through it:
┌────────────────────────────────────────────┐
│ Authentik (auth.yourdomain.com)│
│ SAML / OIDC / LDAP / Proxy │
└────┬───────┬───────┬───────┬──────┬─────────┘
│ │ │ │ │
Gitea Outline Nextcloud n8n Mattermost
One account, one password, one MFA method. When someone leaves, you disable them in Authentik and they lose access to everything instantly. No hunting through 12 admin panels.
Add Authentik's OIDC provider details to each app's SSO settings. It takes 30 minutes to wire up the whole stack once.
Monitoring: Uptime Kuma
Uptime Kuma pings every service every 60 seconds and sends alerts via Telegram, Discord, email, or webhook. Add a status page:
status.yourdomain.com → Uptime Kuma public status page
Now when someone asks "is the wiki down?", send them to the status page instead of checking 12 services manually.
What This Stack Costs vs SaaS
| Service | SaaS (monthly, 10 users) | Selfhosted |
|---|---|---|
| $60 (Google Workspace) | $0 | |
| File storage | $100 (Google Drive) | $0 |
| Git hosting | $40 (GitHub Team) | $0 |
| Password manager | $40 (1Password) | $0 |
| Wiki | $100 (Notion) | $0 |
| Chat | $80 (Slack) | $0 |
| CI/CD | $200 (Vercel/GitHub Actions) | $0 |
| Error tracking | $150 (Sentry) | $0 |
| Analytics | $90 (Plausible + GA) | $0 |
| Newsletter | $300 (Mailchimp) | $0 |
| Auth | $100 (Auth0) | $0 |
| Automation | $200 (Zapier) | $0 |
| Total | $1,460/mo | $0 + server cost |
ServerGurus Bare Metal E-2488: $99/month. You keep $1,361/month - $16,332/year - that previously went to SaaS vendors.
When Not to Self-Host
Self-hosting is not always right:
- You have no one who understands Docker/PostgreSQL at all. The stack needs basic sysadmin knowledge. If your team is entirely non-technical, stay on SaaS.
- Compliance requires SOC 2 Type II from every service. You can self-host with compliance, but it requires documented processes, access controls, and audit logs. Factor that time in.
- Email deliverability is mission-critical and you cannot afford warm-up time. Use a transactional email service (SendGrid, SES) for critical mail and self-host everything else.
Getting Started with ServerGurus
We offer the selfhosted stack as a one-click deployment on our bare metal servers:
- Provision a Bare Metal E-2488 or above
- Select "Selfhosted Stack" from our app marketplace
- Our deployment script runs, Caddy gets SSL certificates, the Docker Compose stack starts
- You get a dashboard with login URLs and initial admin credentials for each service
- Point your DNS A record at the server IP
From zero to a fully functional selfhosted stack in under 30 minutes. Existing data migrations (Google Drive → Nextcloud, GitHub → Gitea, 1Password → Vaultwarden) are included.
Self-hosting is not about rejecting SaaS - it is about reclaiming control over the tools your team depends on every day. The open source ecosystem in 2026 has caught up. The SaaS wrapping paper is getting thinner. Take it off.