Most "ditch SaaS and self-host everything" posts stop at the exciting part. Here's a list of open-source tools, here's how much you'll save, good luck. Then you're left staring at a blank VPS on a Saturday morning wondering how to actually move your team over without losing data or locking anyone out.
This post is the boring, useful middle bit. We'll take three tools a lot of small teams pay for per seat (error tracking, a password manager and web analytics), move them to self-hosted equivalents on one server, and do it in an order that lets you roll back at every step.
The three swaps:
| Paying for | Moving to | Why this one |
|---|---|---|
| Sentry | GlitchTip | Works with the official Sentry SDKs, so you only change a DSN |
| 1Password / Bitwarden Teams | Vaultwarden | Works with the official Bitwarden apps and browser extensions |
| Google Analytics | Umami | Small, cookie-free, runs on the Postgres you already have |
I picked these because they're the lowest risk migrations I know of. None of them need you to rewrite code or retrain your team on a new interface.
Before you start: is this actually worth it for you?
Be honest with yourself here. Self-hosting trades a monthly bill for a bit of ongoing responsibility: updates, backups and the occasional "why is the disk full" moment. It tends to make sense when:
- You're paying per seat and you keep avoiding inviting people because of it
- Someone on the team is comfortable with Linux and Docker
- You can live with the odd hour of downtime that you fix yourself
If nobody wants to own the box, stay on SaaS. That's a perfectly good answer.
Step 1: Get one server and a domain sorted
For these three apps, a VPS with 4 GB of RAM and 2 vCPUs is plenty. Rough idle memory use from my notes: Vaultwarden around 128 MB, Umami around 384 MB, GlitchTip around 512 MB, plus Postgres and Redis shared between them. You'll have headroom.
Point three DNS A records at the server before you do anything else, so TLS certificates can be issued on first boot:
errors.example.com A 203.0.113.10
vault.example.com A 203.0.113.10
stats.example.com A 203.0.113.10
Install Docker and the Compose plugin, open ports 80 and 443, and close everything else.
Step 2: One Postgres, one Redis, one reverse proxy
The biggest mistake I see in self-hosted setups is every app shipping its own database container. You end up with four Postgres instances eating RAM and four things to back up. Share them instead.
Here's a trimmed docker-compose.yml to show the shape of it:
services:
postgres:
image: postgres:16
restart: unless-stopped
env_file: .env
volumes:
- pgdata:/var/lib/postgresql/data
- ./init-dbs.sql:/docker-entrypoint-initdb.d/init-dbs.sql:ro
redis:
image: redis:7
restart: unless-stopped
glitchtip-migrate:
image: glitchtip/glitchtip
command: ./manage.py migrate
depends_on: [postgres, redis]
environment:
DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@postgres:5432/glitchtip
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
glitchtip-web:
image: glitchtip/glitchtip
restart: unless-stopped
depends_on:
glitchtip-migrate:
condition: service_completed_successfully
environment:
DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@postgres:5432/glitchtip
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
GLITCHTIP_DOMAIN: https://errors.example.com
glitchtip-worker:
image: glitchtip/glitchtip
restart: unless-stopped
command: ./bin/run-celery-with-beat.sh
depends_on:
glitchtip-migrate:
condition: service_completed_successfully
environment:
DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@postgres:5432/glitchtip
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
vaultwarden:
image: vaultwarden/server
restart: unless-stopped
environment:
DOMAIN: https://vault.example.com
SIGNUPS_ALLOWED: "false"
ADMIN_TOKEN: ${VAULTWARDEN_ADMIN_TOKEN}
volumes:
- vwdata:/data
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped
depends_on: [postgres]
environment:
DATABASE_URL: postgres://umami:${UMAMI_DB_PASSWORD}@postgres:5432/umami
APP_SECRET: ${UMAMI_APP_SECRET}
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
volumes:
pgdata:
vwdata:
caddy_data:
A few things worth calling out:
-
SIGNUPS_ALLOWED: "false"on Vaultwarden matters. Leave it open and anyone who finds the URL can make an account. Invite people from the admin page instead. - The GlitchTip worker is easy to forget. Without it, events get accepted but never processed, and you'll think the migration failed.
- Same goes for
glitchtip-migrate. It runs the database migrations once and exits. I left it out of my first draft and the worker spat outrelation "uptime_monitor" does not existerrors and the API threw 500s. Seeing it as "Exited (0)" indocker compose psis normal. - Pin image versions once things work.
latestis fine for a first run, not for something your team depends on.
The init-dbs.sql file creates a database and user per app, so each one is isolated even though they share a server:
-- Runs once, on first start of an empty Postgres volume.
-- Passwords here must match the ones in your .env file.
CREATE USER glitchtip WITH PASSWORD 'change-me-glitchtip';
CREATE DATABASE glitchtip OWNER glitchtip;
CREATE USER umami WITH PASSWORD 'change-me-umami';
CREATE DATABASE umami OWNER umami;
Two gotchas here. It only runs the very first time Postgres starts with an empty volume, so if you change it later you'll need to create the users by hand (or wipe the volume on a fresh install). And the passwords in it have to match GLITCHTIP_DB_PASSWORD and UMAMI_DB_PASSWORD in your .env.
Step 3: Caddy does TLS so you don't have to
This is the part that used to eat whole weekends. With Caddy it's a few lines:
errors.example.com {
reverse_proxy glitchtip-web:8000
}
vault.example.com {
reverse_proxy vaultwarden:80
}
stats.example.com {
reverse_proxy umami:3000
}
Caddy grabs and renews Let's Encrypt certificates on its own. If certs aren't issuing, it's almost always DNS not pointing at the box yet, or port 80 being blocked.
Step 4: Secrets in a .env file, never in the compose file
Keep every password and key in .env. Postgres needs one too, so include a POSTGRES_PASSWORD line or the container refuses to start. Commit a .env.example with blank values, and add .env to .gitignore. Generate real values with something like:
openssl rand -base64 48
Do one per secret. Don't reuse them across apps.
Then docker compose up -d and check docker compose logs -f until everything settles.
Step 5: Migrate in the safest order
This is the bit most guides skip. Do it one app at a time, and keep the old SaaS running until you're sure.
Analytics first (lowest stakes). Add the Umami script to your site alongside Google Analytics. Run both for a week or two. Compare the numbers. Umami will usually show a bit more traffic, because privacy blockers don't catch it as often. Once you trust it, remove the GA tag. Your historical GA data stays in GA, so export anything you care about first.
Error tracking second. GlitchTip accepts events from the official Sentry SDKs, so the switch is literally changing SENTRY_DSN to the DSN from your new GlitchTip project. Do it in staging first, throw a test error, and confirm it shows up. Then roll it to production. If something goes wrong, change the DSN back. That's your whole rollback plan.
Passwords last (highest stakes). Export from your current manager, import into Vaultwarden through the web vault, and have everyone point their existing Bitwarden apps at your custom server URL. Then:
- Spot check a handful of shared items and any TOTP codes
- Keep the old vault read-only for a month or so
- Securely delete the export file. It's every password you own in plain text
Turn on 2FA for every account on the new vault before you cancel the old one.
Step 6: Backups, or none of this counts
A self-hosted app without tested backups is just a slow-motion outage. At minimum:
- A nightly
pg_dumpallof Postgres - A copy of the Vaultwarden
/datavolume - Both pushed somewhere off the server (any S3-compatible bucket is fine) and encrypted
Then actually restore one to a scratch machine. If you've never done a restore, you don't have backups yet, you have hopes.
Step 7: Updates without drama
Once a month or so:
docker compose pull
docker compose up -d
docker image prune -f
Read the release notes for Vaultwarden and GlitchTip before bumping major versions. Take a backup first. Boring is the goal.
What about Kubernetes?
If your team already runs a cluster, the same services translate fine to a k8s.yaml with Deployments, Services and an Ingress. If you don't already run Kubernetes, don't adopt it for three small apps. Compose on one box is easier to understand at 2am.
Shortcut if you don't want to hand-write all this
I got tired of rewriting the same compose files, so I built StackSwitcher. You pick the tools you want to replace, and it shows rough RAM and CPU needs and generates starter docker-compose.yml, Caddyfile, .env.example and k8s.yaml files you can preview for free. There are also side by side guides like Sentry vs GlitchTip and Google Analytics vs Umami if you want more detail on a particular swap. Treat whatever it generates as a starting point and read through it, same as anything you copy off the internet (this post included).
Wrapping up
The order is the whole trick: shared database and proxy first, then analytics, then errors, then passwords, with the old service still running behind each step. Do it that way and the worst case is flipping a DSN or a script tag back.
If you've done a migration like this and hit something I didn't cover, I'd genuinely like to hear about it in the comments.
The usual disclaimer: this is general information, not professional advice. Configs and steps are examples only and come with no warranty. Test everything in your own environment, keep backups, and check each project's license and docs before relying on it. I'm not responsible for any data loss, downtime or costs that come from using it.
Top comments (0)