<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrew Munro</title>
    <description>The latest articles on DEV Community by Andrew Munro (@andymunro).</description>
    <link>https://dev.to/andymunro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4214963%2F6fb45d83-e3ac-42b2-9b19-dee57f1823ab.png</url>
      <title>DEV Community: Andrew Munro</title>
      <link>https://dev.to/andymunro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andymunro"/>
    <language>en</language>
    <item>
      <title>Moving Off Per-Seat SaaS Without Breaking Anything: A Weekend Migration Walkthrough</title>
      <dc:creator>Andrew Munro</dc:creator>
      <pubDate>Thu, 29 Oct 2026 10:13:51 +0000</pubDate>
      <link>https://dev.to/andymunro/moving-off-per-seat-saas-without-breaking-anything-a-weekend-migration-walkthrough-27gf</link>
      <guid>https://dev.to/andymunro/moving-off-per-seat-saas-without-breaking-anything-a-weekend-migration-walkthrough-27gf</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The three swaps:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Paying for&lt;/th&gt;
&lt;th&gt;Moving to&lt;/th&gt;
&lt;th&gt;Why this one&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;GlitchTip&lt;/td&gt;
&lt;td&gt;Works with the official Sentry SDKs, so you only change a DSN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1Password / Bitwarden Teams&lt;/td&gt;
&lt;td&gt;Vaultwarden&lt;/td&gt;
&lt;td&gt;Works with the official Bitwarden apps and browser extensions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Analytics&lt;/td&gt;
&lt;td&gt;Umami&lt;/td&gt;
&lt;td&gt;Small, cookie-free, runs on the Postgres you already have&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start: is this actually worth it for you?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're paying per seat and you keep avoiding inviting people because of it&lt;/li&gt;
&lt;li&gt;Someone on the team is comfortable with Linux and Docker&lt;/li&gt;
&lt;li&gt;You can live with the odd hour of downtime that you fix yourself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If nobody wants to own the box, stay on SaaS. That's a perfectly good answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get one server and a domain sorted
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Point three DNS A records at the server before you do anything else, so TLS certificates can be issued on first boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;errors.example.com    A   203.0.113.10
vault.example.com     A   203.0.113.10
stats.example.com     A   203.0.113.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install Docker and the Compose plugin, open ports 80 and 443, and close everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: One Postgres, one Redis, one reverse proxy
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here's a trimmed &lt;code&gt;docker-compose.yml&lt;/code&gt; to show the shape of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./init-dbs.sql:/docker-entrypoint-initdb.d/init-dbs.sql:ro&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;glitchtip-migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;glitchtip/glitchtip&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./manage.py migrate&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@postgres:5432/glitchtip&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://redis:6379/0&lt;/span&gt;
      &lt;span class="na"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GLITCHTIP_SECRET_KEY}&lt;/span&gt;

  &lt;span class="na"&gt;glitchtip-web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;glitchtip/glitchtip&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;glitchtip-migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@postgres:5432/glitchtip&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://redis:6379/0&lt;/span&gt;
      &lt;span class="na"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GLITCHTIP_SECRET_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;GLITCHTIP_DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://errors.example.com&lt;/span&gt;

  &lt;span class="na"&gt;glitchtip-worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;glitchtip/glitchtip&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./bin/run-celery-with-beat.sh&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;glitchtip-migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@postgres:5432/glitchtip&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://redis:6379/0&lt;/span&gt;
      &lt;span class="na"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GLITCHTIP_SECRET_KEY}&lt;/span&gt;

  &lt;span class="na"&gt;vaultwarden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vaultwarden/server&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://vault.example.com&lt;/span&gt;
      &lt;span class="na"&gt;SIGNUPS_ALLOWED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;ADMIN_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${VAULTWARDEN_ADMIN_TOKEN}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vwdata:/data&lt;/span&gt;

  &lt;span class="na"&gt;umami&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/umami-software/umami:postgresql-latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://umami:${UMAMI_DB_PASSWORD}@postgres:5432/umami&lt;/span&gt;
      &lt;span class="na"&gt;APP_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${UMAMI_APP_SECRET}&lt;/span&gt;

  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:2&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vwdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SIGNUPS_ALLOWED: "false"&lt;/code&gt; on Vaultwarden matters. Leave it open and anyone who finds the URL can make an account. Invite people from the admin page instead.&lt;/li&gt;
&lt;li&gt;The GlitchTip worker is easy to forget. Without it, events get accepted but never processed, and you'll think the migration failed.&lt;/li&gt;
&lt;li&gt;Same goes for &lt;code&gt;glitchtip-migrate&lt;/code&gt;. It runs the database migrations once and exits. I left it out of my first draft and the worker spat out &lt;code&gt;relation "uptime_monitor" does not exist&lt;/code&gt; errors and the API threw 500s. Seeing it as "Exited (0)" in &lt;code&gt;docker compose ps&lt;/code&gt; is normal.&lt;/li&gt;
&lt;li&gt;Pin image versions once things work. &lt;code&gt;latest&lt;/code&gt; is fine for a first run, not for something your team depends on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;init-dbs.sql&lt;/code&gt; file creates a database and user per app, so each one is isolated even though they share a server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Runs once, on first start of an empty Postgres volume.&lt;/span&gt;
&lt;span class="c1"&gt;-- Passwords here must match the ones in your .env file.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;glitchtip&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'change-me-glitchtip'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;glitchtip&lt;/span&gt; &lt;span class="k"&gt;OWNER&lt;/span&gt; &lt;span class="n"&gt;glitchtip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;umami&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'change-me-umami'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;umami&lt;/span&gt; &lt;span class="k"&gt;OWNER&lt;/span&gt; &lt;span class="n"&gt;umami&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;GLITCHTIP_DB_PASSWORD&lt;/code&gt; and &lt;code&gt;UMAMI_DB_PASSWORD&lt;/code&gt; in your &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Caddy does TLS so you don't have to
&lt;/h2&gt;

&lt;p&gt;This is the part that used to eat whole weekends. With Caddy it's a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;errors.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;glitchtip-web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="s"&gt;vault.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;vaultwarden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="s"&gt;stats.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;umami&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Secrets in a .env file, never in the compose file
&lt;/h2&gt;

&lt;p&gt;Keep every password and key in &lt;code&gt;.env&lt;/code&gt;. Postgres needs one too, so include a &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; line or the container refuses to start. Commit a &lt;code&gt;.env.example&lt;/code&gt; with blank values, and add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;. Generate real values with something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 48
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do one per secret. Don't reuse them across apps.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;docker compose up -d&lt;/code&gt; and check &lt;code&gt;docker compose logs -f&lt;/code&gt; until everything settles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Migrate in the safest order
&lt;/h2&gt;

&lt;p&gt;This is the bit most guides skip. Do it one app at a time, and keep the old SaaS running until you're sure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics first (lowest stakes).&lt;/strong&gt; Add the Umami script to your site &lt;em&gt;alongside&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error tracking second.&lt;/strong&gt; GlitchTip accepts events from the official Sentry SDKs, so the switch is literally changing &lt;code&gt;SENTRY_DSN&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passwords last (highest stakes).&lt;/strong&gt; 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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spot check a handful of shared items and any TOTP codes&lt;/li&gt;
&lt;li&gt;Keep the old vault read-only for a month or so&lt;/li&gt;
&lt;li&gt;Securely delete the export file. It's every password you own in plain text&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Turn on 2FA for every account on the new vault before you cancel the old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Backups, or none of this counts
&lt;/h2&gt;

&lt;p&gt;A self-hosted app without tested backups is just a slow-motion outage. At minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A nightly &lt;code&gt;pg_dumpall&lt;/code&gt; of Postgres&lt;/li&gt;
&lt;li&gt;A copy of the Vaultwarden &lt;code&gt;/data&lt;/code&gt; volume&lt;/li&gt;
&lt;li&gt;Both pushed somewhere off the server (any S3-compatible bucket is fine) and encrypted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then actually restore one to a scratch machine. If you've never done a restore, you don't have backups yet, you have hopes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Updates without drama
&lt;/h2&gt;

&lt;p&gt;Once a month or so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the release notes for Vaultwarden and GlitchTip before bumping major versions. Take a backup first. Boring is the goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Kubernetes?
&lt;/h2&gt;

&lt;p&gt;If your team already runs a cluster, the same services translate fine to a &lt;code&gt;k8s.yaml&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shortcut if you don't want to hand-write all this
&lt;/h2&gt;

&lt;p&gt;I got tired of rewriting the same compose files, so I built &lt;a href="https://stackswitcher.com" rel="noopener noreferrer"&gt;StackSwitcher&lt;/a&gt;. You pick the tools you want to replace, and it shows rough RAM and CPU needs and generates starter &lt;code&gt;docker-compose.yml&lt;/code&gt;, &lt;code&gt;Caddyfile&lt;/code&gt;, &lt;code&gt;.env.example&lt;/code&gt; and &lt;code&gt;k8s.yaml&lt;/code&gt; files you can preview for free. There are also side by side guides like &lt;a href="https://stackswitcher.com/compare/sentry-vs-glitchtip.html" rel="noopener noreferrer"&gt;Sentry vs GlitchTip&lt;/a&gt; and &lt;a href="https://stackswitcher.com/compare/google-analytics-vs-umami.html" rel="noopener noreferrer"&gt;Google Analytics vs Umami&lt;/a&gt; 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).&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
