DEV Community

Andrew Munro
Andrew Munro

Posted on

Moving Off Per-Seat SaaS Without Breaking Anything: A Weekend Migration Walkthrough

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
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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 out relation "uptime_monitor" does not exist errors and the API threw 500s. Seeing it as "Exited (0)" in docker compose ps is normal.
  • Pin image versions once things work. latest is 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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Spot check a handful of shared items and any TOTP codes
  2. Keep the old vault read-only for a month or so
  3. 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_dumpall of Postgres
  • A copy of the Vaultwarden /data volume
  • 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
Enter fullscreen mode Exit fullscreen mode

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)