<?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: Benjamin Touchard</title>
    <description>The latest articles on DEV Community by Benjamin Touchard (@benjy33000).</description>
    <link>https://dev.to/benjy33000</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%2F1740181%2F3f1038da-05fb-410f-832b-0bd4c2f84fc9.png</url>
      <title>DEV Community: Benjamin Touchard</title>
      <link>https://dev.to/benjy33000</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/benjy33000"/>
    <language>en</language>
    <item>
      <title>Six things to monitor in a Docker Compose stack</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:35:08 +0000</pubDate>
      <link>https://dev.to/benjy33000/six-things-to-monitor-in-a-docker-compose-stack-270h</link>
      <guid>https://dev.to/benjy33000/six-things-to-monitor-in-a-docker-compose-stack-270h</guid>
      <description>&lt;p&gt;You have deployed your stack with Docker Compose. Your containers are running. Everything seems to be working. But what happens when something breaks at 3 AM?&lt;/p&gt;

&lt;p&gt;This checklist covers the six things worth monitoring in a Compose stack, whatever tool you use to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Containers
&lt;/h2&gt;

&lt;p&gt;The most basic level: are your containers running?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container state&lt;/strong&gt;: running, stopped, restarting, exited&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart loops&lt;/strong&gt;: a container stuck in a restart loop is often worse than one that has stopped entirely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker health checks&lt;/strong&gt;: the native &lt;code&gt;HEALTHCHECK&lt;/code&gt; is underused but extremely valuable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit codes&lt;/strong&gt;: a non-zero exit code indicates a crash, not a clean shutdown&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A health check in your &lt;code&gt;docker-compose.yml&lt;/code&gt;:&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;api&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;myapp:latest&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;40s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your monitoring should alert when a container transitions to &lt;code&gt;unhealthy&lt;/code&gt;, not only when it stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Endpoints
&lt;/h2&gt;

&lt;p&gt;Check that your services respond correctly to requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP checks
&lt;/h3&gt;

&lt;p&gt;Beyond "does it respond", a proper HTTP check verifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The status code (200, 201, etc.)&lt;/li&gt;
&lt;li&gt;The response time (latency)&lt;/li&gt;
&lt;li&gt;SSL certificate validity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As one example, Maintenant reads this from Docker labels on the service, so the check lives next to the code:&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;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;maintenant.endpoint.http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/health"&lt;/span&gt;
  &lt;span class="na"&gt;maintenant.endpoint.interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30s"&lt;/span&gt;
  &lt;span class="na"&gt;maintenant.endpoint.http.expected-status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fesk89d93tnzogjjy6tep.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fesk89d93tnzogjjy6tep.webp" alt="HTTP/TCP endpoint monitoring: latency, status codes" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  TCP checks
&lt;/h3&gt;

&lt;p&gt;For services that do not speak HTTP (databases, Redis, MQTT), a TCP check verifies that the port is open and accepting connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. SSL certificates
&lt;/h2&gt;

&lt;p&gt;Expired certificates are one of the most common causes of preventable downtime. With Let's Encrypt and automatic renewal, it is tempting to assume everything just works, but renewals can fail silently. Alert well ahead of expiry (for example at 30, 14, 7, 3 and 1 day) so a failed renewal is noticed while there is still time to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cron jobs
&lt;/h2&gt;

&lt;p&gt;Scheduled tasks are the most overlooked services. Is your nightly backup actually running? Did your cleanup job finish successfully?&lt;/p&gt;

&lt;p&gt;Use the heartbeat pattern: the job sends a ping to a URL after each execution, and you get alerted if the ping does not arrive within the expected window. Report the exit code and send the ping whether the job succeeded or not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# crontab: ';' not '&amp;amp;&amp;amp;', so the ping is sent even when backup.sh fails&lt;/span&gt;
0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/backup.sh&lt;span class="p"&gt;;&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null https://monitor.example.com/ping/&lt;span class="o"&gt;{&lt;/span&gt;uuid&lt;span class="o"&gt;}&lt;/span&gt;/&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. System resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disk space&lt;/strong&gt;: a full disk can crash your database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory usage&lt;/strong&gt;: per container and per host&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU&lt;/strong&gt;: to detect abnormal spikes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network I/O&lt;/strong&gt;: to identify congestion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set thresholds with some margin (for example warn at 80% disk, not at 100%), and keep history so you can tell a spike from a trend. The Docker stats API gives per-container CPU, memory, network and block I/O.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdgpqoewwcz018phdonp.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdgpqoewwcz018phdonp.webp" alt="System resources: CPU, memory, disk, network per container" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Image updates
&lt;/h2&gt;

&lt;p&gt;Knowing that a security update is available for your PostgreSQL or Redis image matters. Comparing the digest of the image you run with the one in the registry tells you when a newer build exists for the tag you use.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://maintenant.dev/blog/docker-compose-monitoring-guide/" rel="noopener noreferrer"&gt;maintenant.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenant&lt;/strong&gt; is an open-source, self-hosted monitoring tool for Docker and Kubernetes: containers, endpoints, cron heartbeats, TLS certificates and alerts, in one container that discovers your stack on its own. Install it in a minute: &lt;a href="https://maintenant.dev" rel="noopener noreferrer"&gt;https://maintenant.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This project was funded by the French Government as part of the France 2030 plan operated by Bpifrance.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo10fjt141n9rw7j6sge.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo10fjt141n9rw7j6sge.png" alt="France 2030 and Cap Digital" width="174" height="56"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Back up Vaultwarden: the database, the data directory, and a restore test</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:30:07 +0000</pubDate>
      <link>https://dev.to/benjy33000/back-up-vaultwarden-the-database-the-data-directory-and-a-restore-test-5399</link>
      <guid>https://dev.to/benjy33000/back-up-vaultwarden-the-database-the-data-directory-and-a-restore-test-5399</guid>
      <description>&lt;p&gt;A password manager restored halfway is worse than one that is lost: it looks complete. The accounts are there, the entries are there, and nobody notices a thing until the day someone types their master password and nothing decrypts.&lt;/p&gt;

&lt;p&gt;This guide covers the whole chain for Vaultwarden: what a backup has to contain besides the database, why copying a SQLite file while it is open is not a backup, a script that cannot report success on a truncated archive, a restore into a throwaway container where you actually open the vault, and the queries that tell you a restored vault is still a vault. It needs only &lt;code&gt;sqlite3&lt;/code&gt;, &lt;code&gt;tar&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt; and the AWS CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vault is not in the database
&lt;/h2&gt;

&lt;p&gt;Vaultwarden knows none of your passwords. Every entry is encrypted in the browser before it is sent, and the server only stores the encrypted blob, in the &lt;code&gt;data&lt;/code&gt; column of the &lt;code&gt;ciphers&lt;/code&gt; table. The master password never leaves the user's machine.&lt;/p&gt;

&lt;p&gt;What the server does keep is each account's &lt;strong&gt;encryption material&lt;/strong&gt;, in the &lt;code&gt;users&lt;/code&gt; table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;akey&lt;/code&gt;, the account's symmetric key, itself encrypted with a key derived from the master password;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;private_key&lt;/code&gt; and &lt;code&gt;public_key&lt;/code&gt;, the RSA pair used for sharing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hence the consequence that makes this subject different from an ordinary application database: &lt;strong&gt;a database restored without that material hands back no readable secret&lt;/strong&gt;. The accounts are there, the entries are there, everyone types their master password, and nothing decrypts, permanently, because the missing key exists nowhere else. A partial export, a truncated column, a &lt;code&gt;users&lt;/code&gt; table restored from a backup older than &lt;code&gt;ciphers&lt;/code&gt;: in all three cases the vault looks complete and it is lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What sits beside the database
&lt;/h2&gt;

&lt;p&gt;Vaultwarden's data directory (&lt;code&gt;/data&lt;/code&gt; in the container) holds what the database does not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rsa_key.pem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the key that signs session tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;attachments/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the attachments of your entries, encrypted as well&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sends/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the files behind Bitwarden Sends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;icon_cache/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;site favicons, a cache, nothing to back up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the settings made from the admin page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;rsa_key.pem&lt;/code&gt; is not the vault's key: it signs tokens. Lose it and Vaultwarden makes another one at start-up, everyone logs in again, and the vaults stay readable. Attachments, on the other hand, are referenced from the database: a database restored alongside an &lt;code&gt;attachments/&lt;/code&gt; tree taken at a different hour gives you entries pointing at files that are not there.&lt;/p&gt;

&lt;p&gt;A Vaultwarden backup is therefore two pieces taken together: the database and the data directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two installs, two ways to back up
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SQLite, the default install
&lt;/h3&gt;

&lt;p&gt;Without &lt;code&gt;DATABASE_URL&lt;/code&gt;, Vaultwarden writes to &lt;code&gt;db.sqlite3&lt;/code&gt;, at the root of the data directory. Copying that file while the service is running is not a backup: SQLite keeps recent transactions in a &lt;code&gt;-wal&lt;/code&gt; journal next to it, and a &lt;code&gt;cp&lt;/code&gt; catches one file and not the other, or both at two different instants.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A hot &lt;code&gt;cp&lt;/code&gt; of &lt;code&gt;db.sqlite3&lt;/code&gt; produces a file that opens&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is what makes the trap expensive: the copied file is not empty, it opens, it contains tables. What is missing is the last writes, or the pages are inconsistent with each other. The &lt;code&gt;.backup&lt;/code&gt; command of &lt;code&gt;sqlite3&lt;/code&gt; takes a read lock, follows the journal, and produces a consistent file without stopping the service.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  PostgreSQL or MySQL
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;DATABASE_URL&lt;/code&gt;, the database lives elsewhere and is backed up like any other, with &lt;code&gt;pg_dump&lt;/code&gt; or &lt;code&gt;mysqldump&lt;/code&gt;. Only the dump line changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nt"&gt;-h&lt;/span&gt; db.internal &lt;span class="nt"&gt;-U&lt;/span&gt; backup_user &lt;span class="nt"&gt;-d&lt;/span&gt; vaultwarden &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/backups/vaultwarden_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In both cases, the data directory is backed up the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backup script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/vaultwarden
&lt;span class="nv"&gt;dest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;s3://backups-vaultwarden

sqlite3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/db.sqlite3"&lt;/span&gt; &lt;span class="s2"&gt;".backup '/backups/db_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sqlite3'"&lt;/span&gt;
&lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="s2"&gt;"/backups/db_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sqlite3"&lt;/span&gt;

&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-cf&lt;/span&gt; - &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--exclude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./icon_cache &lt;span class="nt"&gt;--exclude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'./db.sqlite3*'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/backups/data_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tar.gz"&lt;/span&gt;

aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"/backups/db_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sqlite3.gz"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dest&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"/backups/data_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tar.gz"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dest&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database is excluded from the archive: &lt;code&gt;.backup&lt;/code&gt; has already taken it cleanly, and adding it to the &lt;code&gt;tar&lt;/code&gt; would put back into the backup the very hot copy we just avoided. &lt;code&gt;icon_cache&lt;/code&gt; is excluded because Vaultwarden rebuilds it on its own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Without &lt;code&gt;pipefail&lt;/code&gt;, a truncated archive comes out as a success&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;tar -cf - ... | gzip &amp;gt; file&lt;/code&gt;, the shell only looks at the exit code of the last link. If &lt;code&gt;tar&lt;/code&gt; stops along the way (a file vanishing under it, a full disk), &lt;code&gt;gzip&lt;/code&gt; still compresses what it received and returns 0. The script returns 0, cron is happy, and the archive holds half the directory. &lt;code&gt;set -o pipefail&lt;/code&gt;, included in the &lt;code&gt;set -euo pipefail&lt;/code&gt; above, makes the whole line fail as soon as &lt;code&gt;tar&lt;/code&gt; fails.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Keep the timestamp in both file names, and keep them &lt;strong&gt;paired&lt;/strong&gt;: what restores is the database and the directory taken in the same minute, not the latest file on each side. For retention, a lifecycle rule on the bucket deletes objects older than N days with no script to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restoring once, by hand
&lt;/h2&gt;

&lt;p&gt;A backup is only proven once restored. Do it once, in full, in a throwaway container. It is also the procedure you will follow the day it matters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/vw-test/data
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; /backups/data_20260918_010000.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /tmp/vw-test/data
&lt;span class="nb"&gt;gunzip&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; /backups/db_20260918_010000.sqlite3.gz &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/vw-test/data/db.sqlite3

docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vw-test &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /tmp/vw-test/data:/data &lt;span class="se"&gt;\&lt;/span&gt;
  vaultwarden/server:&amp;lt;your version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The image must carry the production version&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vaultwarden applies its schema migrations at start-up. An image older than the one that wrote the database refuses to start on it, and a newer image migrates the restored file, which is the right behaviour for a real restore, but not what you want from a test. Use your production's exact version, not &lt;code&gt;latest&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then open &lt;code&gt;http://localhost:8080&lt;/code&gt; in a browser, log in with an account's master password, and &lt;strong&gt;display the password of one entry&lt;/strong&gt;. That is the only step that proves the vault decrypts. It needs a human who knows a master password: nobody else can do it, and that is exactly what you want from a password manager.&lt;/p&gt;

&lt;p&gt;Note how long it took. That is your real restore duration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in a restored database
&lt;/h2&gt;

&lt;p&gt;That the file opens does not mean the vault is there. Four questions, in this order:&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="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ciphers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ciphers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
   &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;akey&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;akey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;private_key&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;public_key&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accounts are left.&lt;/strong&gt; An account with no password hash is an invited user who never logged in, not a user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entries are left.&lt;/strong&gt; Compare against the order of magnitude in production, not an exact number: a vault that grows is normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The entries are recent.&lt;/strong&gt; The last change should be yesterday's, not last month's. That is what catches a backup job that stopped running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The encryption material followed.&lt;/strong&gt; This query must return zero. An active account that lost its &lt;code&gt;akey&lt;/code&gt; or its key pair is an unreadable vault that looks intact, the case described at the top of this page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To check the integrity of a SQLite file, &lt;code&gt;sqlite3 db.sqlite3 'pragma integrity_check;'&lt;/code&gt; reads every page, which a &lt;code&gt;sha256sum&lt;/code&gt; does not: a digest proves the file has not changed since the copy, not that it was valid when copied. Run it on a copy, because SQLite needs to write to replay the &lt;code&gt;-wal&lt;/code&gt; journal when opening.&lt;/p&gt;

&lt;p&gt;On the file side, two commands are enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /tmp/vw-test/data/rsa_key.pem
find /tmp/vw-test/data &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signing key is there, and the tree has not melted away. Remember that second number: it is a floor to compare against on later restores.&lt;/p&gt;

&lt;p&gt;Then destroy everything: &lt;code&gt;docker rm -f vw-test&lt;/code&gt; and &lt;code&gt;rm -rf /tmp/vw-test&lt;/code&gt;. What you have just handled is a production vault.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this chain does not prove
&lt;/h2&gt;

&lt;p&gt;It proves that a recent backup opens, that it contains accounts and entries, and that each account's encryption material is intact. It does not prove that an entry decrypts: that needs a master password, which has no business being in a script. If the database and the data directory are checked separately, nothing verifies that every attachment cited by the database exists in the archive, which is why the two backups are taken in the same minute. And it says nothing about what has been added to the vault since the last backup: that gap is your RPO, and it is tuned with the backup frequency, not with the tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is backing up the Vaultwarden database enough?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The data directory holds the key that signs session tokens, the attachments of your entries, and the files behind Sends. More importantly, the two pieces depend on each other: attachments are referenced from the database, so a database and a file tree taken an hour apart give you entries pointing at files that are not there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can &lt;code&gt;db.sqlite3&lt;/code&gt; be copied while Vaultwarden is running?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not with &lt;code&gt;cp&lt;/code&gt;. SQLite keeps recent writes in a &lt;code&gt;-wal&lt;/code&gt; journal next to the main file, and a plain copy catches one without the other. The &lt;code&gt;.backup&lt;/code&gt; command of &lt;code&gt;sqlite3&lt;/code&gt; produces a consistent file without stopping the service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a Vaultwarden backup contain my passwords in the clear?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No: every entry is encrypted in the browser before it reaches the server, and the master password never leaves the user's machine. A backup still has to be treated like the vault itself, because it contains everything needed to attack the master passwords offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you verify that a restored vault really decrypts?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By logging in with a master password and displaying an entry. An automated check cannot do that, and should not be able to: there is no place to put a master password that is not also a place to steal it from. What an automated check can verify is that each account's encryption material is present and complete, which is the failure nobody sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which image version should the restore test use?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your production's exact version, not &lt;code&gt;latest&lt;/code&gt;. Vaultwarden applies its schema migrations at start-up: an image older than the one that wrote the database refuses to start on it, and a newer image migrates the restored file. That is the right behaviour for a real restore, not for a test, which has to read the database as it was backed up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://restoreproof.io/blog/back-up-vaultwarden-and-verify-the-vault" rel="noopener noreferrer"&gt;restoreproof.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RestoreProof&lt;/strong&gt; runs this kind of restore test on a schedule, inside your own infrastructure, and signs a report proving the backup restores. Your data never leaves your servers. Free plan, no credit card: &lt;a href="https://app.restoreproof.io" rel="noopener noreferrer"&gt;https://app.restoreproof.io&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>security</category>
      <category>backup</category>
      <category>docker</category>
    </item>
    <item>
      <title>Deploying a Go + Vue SaaS on one VPS with Traefik and Let's Encrypt</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Wed, 28 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/benjy33000/deploying-a-go-vue-saas-on-one-vps-with-traefik-and-lets-encrypt-10b</link>
      <guid>https://dev.to/benjy33000/deploying-a-go-vue-saas-on-one-vps-with-traefik-and-lets-encrypt-10b</guid>
      <description>&lt;p&gt;A SaaS that compiles into one binary does not need a deployment platform. It&lt;br&gt;
needs a server, a proxy that gets certificates, and a backup you have&lt;br&gt;
restored at least once.&lt;/p&gt;

&lt;p&gt;Here is the complete setup, in the order you would actually do it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you are building
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Internet
           │  :80 / :443
      ┌────▼─────┐   Let's Encrypt certificates, HTTP→HTTPS
      │  Traefik │
      └────┬─────┘
           │  docker network "traefik"
      ┌────▼─────┐   one process: API + embedded Vue app + job worker
      │   app    │
      └────┬─────┘
           │
      ┌────▼─────┐   not exposed to the internet
      │ postgres │
      └──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two containers you wrote nothing for, one that is your product. A 1 vCPU /&lt;br&gt;
2 GB server runs this comfortably at the start.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: the server and DNS
&lt;/h2&gt;

&lt;p&gt;Any provider works; for EU data residency, Hetzner, Scaleway and OVHcloud are&lt;br&gt;
the usual answers. Point an &lt;code&gt;A&lt;/code&gt; record (and &lt;code&gt;AAAA&lt;/code&gt; if you have IPv6) at the&lt;br&gt;
server, install Docker, and do the boring hardening: SSH keys only, automatic&lt;br&gt;
security updates, a firewall that allows 22, 80 and 443.&lt;/p&gt;

&lt;p&gt;DNS first, because Let's Encrypt validates over HTTP and will fail politely&lt;br&gt;
until the record resolves.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: Traefik, once per host
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create traefik
&lt;span class="nv"&gt;ACME_EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@example.com docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/traefik/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Traefik now owns ports 80 and 443, redirects HTTP to HTTPS, and requests&lt;br&gt;
certificates for any container that asks through labels. You do this once; a&lt;br&gt;
second application on the same host joins the same network.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: configure the app
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The variables that must be right in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APP_ENV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;production&lt;/code&gt; — turns on secure cookie flags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;https://app.example.com&lt;/code&gt;, used in emails and OAuth callbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DOMAIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;app.example.com&lt;/code&gt;, used by the Traefik labels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CSRF_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openssl rand -hex 32&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;something random you never type again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB_DRIVER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MAILER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resend&lt;/code&gt; (+ &lt;code&gt;RESEND_API_KEY&lt;/code&gt;) or &lt;code&gt;smtp&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SUPER_ADMIN_EMAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the address allowed into &lt;code&gt;/admin&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TRUSTED_PROXIES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the docker network CIDR, so &lt;code&gt;X-Forwarded-For&lt;/code&gt; is trusted only from Traefik&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of them — &lt;code&gt;APP_ENV&lt;/code&gt;, &lt;code&gt;DB_DRIVER&lt;/code&gt; and &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; — describe this&lt;br&gt;
stack's topology and are already set by &lt;code&gt;docker-compose.prod.yml&lt;/code&gt;, which wins&lt;br&gt;
over &lt;code&gt;.env&lt;/code&gt;. They are yours to fill in only if you deploy without that file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;make setup&lt;/code&gt; writes most of this interactively if you prefer answering&lt;br&gt;
questions to reading a table.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: ship it
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The multi-stage build compiles the Vue app, embeds it in the Go binary and&lt;br&gt;
produces a distroless image running as a non-root user. Migrations run at&lt;br&gt;
startup, so there is no separate migrate step to forget.&lt;/p&gt;

&lt;p&gt;Two minutes later &lt;code&gt;https://app.example.com&lt;/code&gt; answers with a valid certificate.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: the webhook and the mail domain
&lt;/h2&gt;

&lt;p&gt;Two things are easy to postpone and painful to discover later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payments.&lt;/strong&gt; Point the Stripe webhook endpoint at&lt;br&gt;
&lt;code&gt;https://app.example.com/api/billing/webhook&lt;/code&gt; and put the signing secret in&lt;br&gt;
&lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt;. Trigger a test event and check the log line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email deliverability.&lt;/strong&gt; Publish SPF, add the DKIM records your provider&lt;br&gt;
generates, and start DMARC at &lt;code&gt;p=none&lt;/code&gt; with a reporting address. Send from a&lt;br&gt;
subdomain (&lt;code&gt;mail.example.com&lt;/code&gt;) so your root domain's reputation is insulated.&lt;br&gt;
Mail without DKIM goes to spam at the two providers your customers use.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 6: backups you have restored
&lt;/h2&gt;

&lt;p&gt;The part that separates a hobby deployment from a business:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# nightly, to somewhere that is not this server&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; db &lt;span class="se"&gt;\&lt;/span&gt;
  pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; govuekit govuekit | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backups/db-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;czf /backups/uploads-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz /var/lib/docker/volumes/&amp;lt;project&amp;gt;_uploads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two volumes, not one. Blobs live outside the database, so a &lt;code&gt;pg_dump&lt;/code&gt; alone&lt;br&gt;
restores accounts with broken avatars and missing attachments. If you set&lt;br&gt;
&lt;code&gt;STORAGE=s3&lt;/code&gt;, the object store holds them instead and only the database is&lt;br&gt;
yours to back up.&lt;/p&gt;

&lt;p&gt;Then do the thing nobody does: restore into a scratch container and sign in.&lt;br&gt;
An untested backup is a belief.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: updating
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git pull
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrations apply on boot. Rollback is the previous image tag, or the previous&lt;br&gt;
binary if you deploy without Docker. Because the frontend is embedded, there&lt;br&gt;
is no window where a new API meets an old bundle — they ship as one artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without Docker, if you prefer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make build              &lt;span class="c"&gt;# bin/govuekit, frontend inside&lt;/span&gt;
scp bin/govuekit server:/opt/myapp/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a &lt;code&gt;systemd&lt;/code&gt; unit with &lt;code&gt;Restart=always&lt;/code&gt;, an &lt;code&gt;EnvironmentFile&lt;/code&gt;, and any&lt;br&gt;
reverse proxy in front — Caddy gets certificates with two lines, nginx needs&lt;br&gt;
certbot. The application does not care: it is one process listening on a port.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to watch once it is live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disk&lt;/strong&gt;, because SQLite files, uploads and Postgres all live on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate renewal&lt;/strong&gt;, which Traefik handles but you should still be told
about if it fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dead-letter log line&lt;/strong&gt; from the mail queue: it is the first sign that
your provider's API key expired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error reports&lt;/strong&gt;: set &lt;code&gt;SENTRY_DSN&lt;/code&gt;, or plug your own reporter — every
Error-level log record reaches it, including failures in background workers
that have no request behind them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the whole operational surface: one binary, one database, one proxy,&lt;br&gt;
two backups. If you want the cost side of the argument, read&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/the-rented-stack-bill" rel="noopener noreferrer"&gt;the rented stack bill&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/deploy-go-saas-vps-traefik" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>go</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>How container auto-discovery works with the Docker SDK and client-go</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:58:00 +0000</pubDate>
      <link>https://dev.to/benjy33000/how-container-auto-discovery-works-with-the-docker-sdk-and-client-go-24ej</link>
      <guid>https://dev.to/benjy33000/how-container-auto-discovery-works-with-the-docker-sdk-and-client-go-24ej</guid>
      <description>&lt;p&gt;A monitoring tool can find your containers on its own, with no list of targets to write. There is no magic involved: it relies on the Docker and Kubernetes APIs, which already know everything about what is running. This post walks through how &lt;a href="https://maintenant.dev" rel="noopener noreferrer"&gt;Maintenant&lt;/a&gt;, a Go monitoring tool, does it with the Docker SDK for Go and client-go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime detection
&lt;/h2&gt;

&lt;p&gt;On startup, the tool runs a detection sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the &lt;code&gt;MAINTENANT_RUNTIME&lt;/code&gt; environment variable is set, it forces the mode&lt;/li&gt;
&lt;li&gt;Otherwise, if &lt;code&gt;KUBERNETES_SERVICE_HOST&lt;/code&gt; is set, it switches to Kubernetes mode with in-cluster config&lt;/li&gt;
&lt;li&gt;Otherwise, it tries a kubeconfig (&lt;code&gt;KUBECONFIG&lt;/code&gt; or &lt;code&gt;~/.kube/config&lt;/code&gt;) and uses it if the cluster answers&lt;/li&gt;
&lt;li&gt;Otherwise, it connects to the Docker socket (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;) and switches to Docker mode&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Docker mode
&lt;/h2&gt;

&lt;p&gt;In Docker mode, the official Docker SDK for Go connects to the Unix socket, mounted read-only. Three things happen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Initial listing
&lt;/h3&gt;

&lt;p&gt;A call to &lt;code&gt;ContainerList&lt;/code&gt; retrieves every container, stopped ones included. For each container, the tool extracts Docker labels, the Compose project name (via the &lt;code&gt;com.docker.compose.project&lt;/code&gt; label), exposed ports, and health check configuration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsaoq999o11k1upqypgx4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsaoq999o11k1upqypgx4.webp" alt="Unified dashboard: all discovered containers at a glance" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Event listening
&lt;/h3&gt;

&lt;p&gt;A dedicated goroutine listens to Docker events in real time via &lt;code&gt;Events&lt;/code&gt;. Every &lt;code&gt;start&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;, &lt;code&gt;die&lt;/code&gt;, and &lt;code&gt;health_status&lt;/code&gt; event is captured and processed immediately, which is what lets the dashboard update in real time over SSE.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics collection
&lt;/h3&gt;

&lt;p&gt;The Docker &lt;code&gt;ContainerStats&lt;/code&gt; API provides CPU, memory, network, and disk I/O metrics per container. They are collected at regular intervals and stored in SQLite.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdgpqoewwcz018phdonp.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdgpqoewwcz018phdonp.webp" alt="System resources: CPU, memory, disk, network monitoring" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes mode
&lt;/h2&gt;

&lt;p&gt;In Kubernetes mode, the tool uses the official client-go library with in-cluster configuration (service account). The required RBAC is minimal and read-only:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maintenant&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&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;namespaces"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nodes"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&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;pods/log"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;apps"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&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;deployments"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statefulsets"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daemonsets"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;batch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&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;jobs"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;metrics.k8s.io"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&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;pods"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nodes"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is created, modified, or deleted in the cluster: observation only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workloads
&lt;/h3&gt;

&lt;p&gt;Unlike Docker mode, which operates at the container level, Kubernetes mode operates at the workload level: Deployments, DaemonSets, StatefulSets. Pods are grouped under their parent workload, which gives a more meaningful view of a service's health than a flat list of pods.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage: SQLite in WAL mode
&lt;/h2&gt;

&lt;p&gt;All collected data is stored in SQLite using WAL mode (Write-Ahead Logging). Readers do not block the writer and the writer does not block readers, which matters for a process that continuously collects metrics while serving a real-time dashboard.&lt;/p&gt;

&lt;p&gt;Memory stays low (under 30 MB at rest) thanks to strict goroutine management and Go's garbage collector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Auto-discovery is the use of existing Docker and Kubernetes APIs (list, then watch events, then poll stats), combined with a lightweight Go architecture and simple storage choices.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://maintenant.dev/blog/auto-discovery-containers/" rel="noopener noreferrer"&gt;maintenant.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenant&lt;/strong&gt; is an open-source, self-hosted monitoring tool for Docker and Kubernetes: containers, endpoints, cron heartbeats, TLS certificates and alerts, in one container that discovers your stack on its own. Install it in a minute: &lt;a href="https://maintenant.dev" rel="noopener noreferrer"&gt;https://maintenant.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This project was funded by the French Government as part of the France 2030 plan operated by Bpifrance.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo10fjt141n9rw7j6sge.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo10fjt141n9rw7j6sge.png" alt="France 2030 and Cap Digital" width="174" height="56"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The rented stack: what a BaaS-based starter really commits you to</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:35:00 +0000</pubDate>
      <link>https://dev.to/benjy33000/the-rented-stack-what-a-baas-based-starter-really-commits-you-to-5h6d</link>
      <guid>https://dev.to/benjy33000/the-rented-stack-what-a-baas-based-starter-really-commits-you-to-5h6d</guid>
      <description>&lt;p&gt;A starter built on managed services gets you to a working product fast. That&lt;br&gt;
is a real benefit, and this post is not going to pretend otherwise. But the&lt;br&gt;
speed is bought with commitments that are invisible on day one and very&lt;br&gt;
visible in year two.&lt;/p&gt;

&lt;p&gt;Here is the full ledger, including the cases where renting wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four bills
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The monthly bill.&lt;/strong&gt; Platform hosting, database, auth, object storage,&lt;br&gt;
email, queue, error tracking. Each is modest alone. Together they are the kind&lt;br&gt;
of number founders discover when they finally read the invoice: the auth&lt;br&gt;
vendor's free tier ends at a few thousand monthly active users, the database&lt;br&gt;
service charges for compute you cannot turn off, and the platform bills seats&lt;br&gt;
for team members who only need to read logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The coupling bill.&lt;/strong&gt; Vendor-specific code is code you cannot take with&lt;br&gt;
you: row-level security written in one vendor's dialect, auth tables you do&lt;br&gt;
not own, storage URLs baked into your frontend, functions written against a&lt;br&gt;
runtime that only exists there. None of it is wasted work — it is just work&lt;br&gt;
you will redo if you leave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The compliance bill.&lt;/strong&gt; Every processor is a line in your GDPR register, a&lt;br&gt;
data-processing agreement to sign, a sub-processor list to keep current, and&lt;br&gt;
an answer you owe to every enterprise buyer's security questionnaire. Five&lt;br&gt;
vendors is five of each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The dependency bill.&lt;/strong&gt; Their incident is your incident. Their price&lt;br&gt;
change is your board meeting. Their deprecation is your sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the self-hosted alternative actually costs
&lt;/h2&gt;

&lt;p&gt;Being fair means quoting the other side properly. Self-hosting is not free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a server to configure, patch and monitor;&lt;/li&gt;
&lt;li&gt;backups you must set up &lt;strong&gt;and restore at least once&lt;/strong&gt;, because an untested
backup is a hope;&lt;/li&gt;
&lt;li&gt;TLS certificates (automated, but yours to notice when they fail);&lt;/li&gt;
&lt;li&gt;an on-call reality where nobody else gets paged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GoVueKit's answer is to make that list as short as it can be: the app is one&lt;br&gt;
binary, sessions and the job queue live in the database, files can stay on&lt;br&gt;
disk, and &lt;code&gt;docker-compose.prod.yml&lt;/code&gt; puts the app behind Traefik with Let's&lt;br&gt;
Encrypt — the reference Traefik stack ships in &lt;code&gt;deploy/traefik/&lt;/code&gt; — so the&lt;br&gt;
certificate part is automatic. The&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/deploy-go-saas-vps-traefik" rel="noopener noreferrer"&gt;deployment guide&lt;/a&gt; is a handful of commands&lt;br&gt;
long. What remains is genuinely your job: backups, monitoring and updates.&lt;/p&gt;

&lt;p&gt;Concretely, the required list is: &lt;strong&gt;a server, a domain&lt;/strong&gt;. Everything else —&lt;br&gt;
PostgreSQL instead of SQLite, S3-compatible storage, an email relay, a payment&lt;br&gt;
provider, an error reporter — is optional and swappable, and the kit boots&lt;br&gt;
without any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exit test
&lt;/h2&gt;

&lt;p&gt;Before you commit to any starter, run this thought experiment for each vendor&lt;br&gt;
in its stack:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The vendor triples its price, or is acquired, or changes its terms for&lt;br&gt;
companies my size. What do I do that week?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Score the answer, honestly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Change a connection string."&lt;/td&gt;
&lt;td&gt;Healthy. Standard protocol, portable data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Export, transform, re-import over a weekend."&lt;/td&gt;
&lt;td&gt;Acceptable. Budget the weekend.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Rewrite auth and migrate users who cannot have their password hashes exported."&lt;/td&gt;
&lt;td&gt;You are not a customer, you are a tenant.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"I do not know."&lt;/td&gt;
&lt;td&gt;Find out before you build on it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two are the ones that decide architecture. They are also the ones the&lt;br&gt;
feature comparison table never mentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where renting genuinely wins
&lt;/h2&gt;

&lt;p&gt;Do not read this as an argument for building everything yourself. Renting is&lt;br&gt;
the right answer when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You are validating.&lt;/strong&gt; If the product might not exist in three months, the
fastest path to a paying user beats every architectural consideration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The service is genuinely hard.&lt;/strong&gt; Payment processing, email delivery
reputation, fraud scoring, video transcoding. GoVueKit rents these too: it
uses Stripe for payments and Resend or an SMTP relay for delivery, because
building either would be malpractice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have no operations capacity at all&lt;/strong&gt; and no intention of acquiring
any. A single founder with no infrastructure background will ship faster on
a platform, and that is a legitimate trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elastic load is your normal.&lt;/strong&gt; If your traffic swings by two orders of
magnitude on a schedule, per-request pricing may genuinely be cheaper than
provisioned capacity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction worth holding: rent &lt;strong&gt;capabilities&lt;/strong&gt; that are hard and&lt;br&gt;
replaceable behind an interface. Avoid renting &lt;strong&gt;your data model, your users&lt;br&gt;
and your runtime&lt;/strong&gt;, because those are the parts you cannot swap.&lt;/p&gt;

&lt;p&gt;That is why GoVueKit puts every outbound integration behind a small seam —&lt;br&gt;
&lt;code&gt;billing.Provider&lt;/code&gt;, &lt;code&gt;email.Mailer&lt;/code&gt;, &lt;code&gt;storage.Storage&lt;/code&gt;, the error reporter —&lt;br&gt;
and keeps users, organizations, sessions, subscriptions and jobs in your own&lt;br&gt;
database. Stripe is the one payment implementation that ships; the seam is&lt;br&gt;
what you write against if you ever need another.&lt;/p&gt;

&lt;h2&gt;
  
  
  The EU angle, without the flag-waving
&lt;/h2&gt;

&lt;p&gt;If you sell to European companies, self-hosting answers three questions at&lt;br&gt;
once: where the data is, who processes it, and what happens if the answer must&lt;br&gt;
change. Host in Frankfurt, Paris or Amsterdam and the answer is "our server,&lt;br&gt;
nobody else, and we can move it".&lt;/p&gt;

&lt;p&gt;That is not compliance in itself — you still need a privacy policy, a&lt;br&gt;
register, and the ability to export and erase a user's data. GoVueKit ships&lt;br&gt;
the last part as product features (GDPR export and account erasure, audit log&lt;br&gt;
for administrative actions) precisely because they are tedious to retrofit.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical middle path
&lt;/h2&gt;

&lt;p&gt;You do not have to choose ideologically. A reasonable sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start self-hosted on one server with SQLite. It costs the price of a coffee
per month and has no vendor in it.&lt;/li&gt;
&lt;li&gt;Move to PostgreSQL when concurrent writes justify it — one environment
variable in GoVueKit, &lt;a href="https://govuekit.dev/blog/sqlite-or-postgres" rel="noopener noreferrer"&gt;same SQL either way&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Add S3-compatible storage when files outgrow the disk or you want two app
instances.&lt;/li&gt;
&lt;li&gt;Rent the genuinely hard things — payments, email delivery — from day one,
behind the interfaces that already exist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You end up with a stack where every rented piece is replaceable in an&lt;br&gt;
afternoon, and nothing rented owns your users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://govuekit.dev/#pricing" rel="noopener noreferrer"&gt;See the dependency ledger on the product page&lt;/a&gt;, or read&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/saas-boilerplate-comparison-2026" rel="noopener noreferrer"&gt;how the boilerplate families compare&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/the-rented-stack-bill" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>architecture</category>
      <category>devops</category>
      <category>startup</category>
    </item>
    <item>
      <title>Back up PostgreSQL to S3: from pg_dump to a verified restore</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Sat, 17 Oct 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/benjy33000/back-up-postgresql-to-s3-from-pgdump-to-a-verified-restore-3lgb</link>
      <guid>https://dev.to/benjy33000/back-up-postgresql-to-s3-from-pgdump-to-a-verified-restore-3lgb</guid>
      <description>&lt;p&gt;Most PostgreSQL backup guides stop at the &lt;code&gt;pg_dump&lt;/code&gt; command line. That is the easy part. What decides whether you get your data back is everything around it: what the dump leaves out, which format you chose, whether the script really fails when the dump fails, and whether anyone has ever replayed the file.&lt;/p&gt;

&lt;p&gt;This guide covers the whole chain: what a PostgreSQL backup has to contain besides the database itself, how to pick between plain SQL, &lt;code&gt;custom&lt;/code&gt; and &lt;code&gt;directory&lt;/code&gt;, a backup script that cannot report success on an empty file, a manual restore in a throwaway container, and four SQL queries that tell you the restored database holds your data. It needs only &lt;code&gt;pg_dump&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt; and the AWS CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a PostgreSQL backup has to contain
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_dump&lt;/code&gt; backs up &lt;strong&gt;one database&lt;/strong&gt;: its tables, its data, its views, its functions. It does not back up what lives beside it, at server level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;roles and their passwords;&lt;/li&gt;
&lt;li&gt;the settings applied to the server (&lt;code&gt;postgresql.conf&lt;/code&gt;, &lt;code&gt;pg_hba.conf&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;the other databases on the same server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roles are retrieved with &lt;code&gt;pg_dumpall --globals-only&lt;/code&gt;, which produces a small separate SQL file. Without it, the restore succeeds, but the application accounts no longer exist and nobody can connect.&lt;/p&gt;

&lt;p&gt;Extensions, on the other hand, are in the dump, as a &lt;code&gt;CREATE EXTENSION&lt;/code&gt;. They must therefore be &lt;strong&gt;installed&lt;/strong&gt; on the server where you restore, otherwise the dump stops on that line. That is the classic trap with &lt;code&gt;postgis&lt;/code&gt; and &lt;code&gt;pgcrypto&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the dump format
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it allows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain SQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pg_dump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;readable, fixable by hand, replayed with &lt;code&gt;psql&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;custom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pg_dump -Fc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;already compressed, selective restore, table by table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;directory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pg_dump -Fd -j 4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the only one produced and restored in parallel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a database of a few gigabytes, compressed plain SQL is enough and stays the easiest to inspect. Beyond that, &lt;code&gt;directory&lt;/code&gt; with &lt;code&gt;-j&lt;/code&gt; divides dump and restore time by the number of cores you give it.&lt;/p&gt;

&lt;p&gt;Do not compress a &lt;code&gt;-Fc&lt;/code&gt; or &lt;code&gt;-Fd&lt;/code&gt; dump: it already is, and gzipping it again only burns CPU for a few percent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backup script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;dest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;s3://backups-billing/postgres

pg_dump &lt;span class="nt"&gt;-h&lt;/span&gt; db.internal &lt;span class="nt"&gt;-U&lt;/span&gt; backup_user &lt;span class="nt"&gt;-d&lt;/span&gt; billing &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/backups/billing_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;

pg_dumpall &lt;span class="nt"&gt;-h&lt;/span&gt; db.internal &lt;span class="nt"&gt;-U&lt;/span&gt; backup_user &lt;span class="nt"&gt;--globals-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/backups/globals_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;

aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"/backups/billing_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dest&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"/backups/globals_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dest&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-owner --no-acl&lt;/code&gt; strips the original owners and privileges from the dump: without them, the restore asks for roles that only exist on the production server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Without &lt;code&gt;pipefail&lt;/code&gt;, a failed dump comes out as a success&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;pg_dump | gzip&lt;/code&gt;, the shell only looks at the exit code of the last link. &lt;code&gt;gzip&lt;/code&gt; succeeds at compressing an empty stream, so the script returns 0 and cron is happy. &lt;code&gt;set -o pipefail&lt;/code&gt;, included in the &lt;code&gt;set -euo pipefail&lt;/code&gt; above, makes the whole line fail as soon as &lt;code&gt;pg_dump&lt;/code&gt; fails. It is the number one cause of empty backups that stay green for months.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Keep the timestamp in the file name: a file always overwritten under the same name leaves no chance of going back to yesterday. For retention, a lifecycle rule on the bucket does the job with no script to maintain: objects older than N days are deleted by the storage itself.&lt;/p&gt;

&lt;p&gt;That leaves running it every night. A &lt;code&gt;cron&lt;/code&gt; entry is enough, on two conditions: its error output goes somewhere someone reads, and a run that drags on does not overlap with the next one. &lt;code&gt;flock -n&lt;/code&gt; on a lock file settles the second point in one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restoring once, by hand
&lt;/h2&gt;

&lt;p&gt;A backup is only proven once restored. Do it once, in full, on a throwaway machine. It is also the procedure you will follow the day it matters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg-test &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;test &lt;/span&gt;postgres:17-alpine
&lt;span class="k"&gt;until &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;pg-test pg_isready &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done

&lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;pg-test createdb &lt;span class="nt"&gt;-U&lt;/span&gt; postgres billing
&lt;span class="nb"&gt;gunzip&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; /backups/globals_20260918_010000.sql.gz &lt;span class="se"&gt;\&lt;/span&gt;
  | docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; pg-test psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-d&lt;/span&gt; postgres
&lt;span class="nb"&gt;gunzip&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; /backups/billing_20260918_010000.sql.gz &lt;span class="se"&gt;\&lt;/span&gt;
  | docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; pg-test psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-d&lt;/span&gt; billing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ON_ERROR_STOP=1&lt;/code&gt; is essential: without it, &lt;code&gt;psql&lt;/code&gt; carries on after an error and you end up with a half-restored database that looks like it works.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Match the server's major version&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;postgres:17-alpine&lt;/code&gt; must match the major version of your server. A dump taken on a newer server does not replay on an older one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Note how long it took. That is your real restore duration, the only one worth comparing to the delay you promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in a restored database
&lt;/h2&gt;

&lt;p&gt;A dump that replayed without an error does not mean the data is there. Four questions, in this order:&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="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;last_value&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices_id_seq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The tables are there.&lt;/strong&gt; Zero tables means an empty dump that replayed perfectly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rows are there.&lt;/strong&gt; Compare against the order of magnitude in production, not an exact number: a database that grows is normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data is recent.&lt;/strong&gt; The most recent date should be yesterday's, not last month's. That is what catches a backup job that stopped running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sequences kept up.&lt;/strong&gt; A sequence left at 1 causes key collisions on the first write after the restore.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then destroy everything: &lt;code&gt;docker rm -f pg-test&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this chain does not prove
&lt;/h2&gt;

&lt;p&gt;It proves that a recent backup restores into a fresh PostgreSQL and that it contains the expected data. It does not prove that your application works on that database: for that, you need to start it against the restored container and request a page. It says nothing either about what has been created since the last dump. That gap is your RPO, and it is tuned with the backup frequency, not with the tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How often should a restore be tested?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As often as the backup runs, if you can. A nightly restore test surfaces a broken backup the morning after it breaks, rather than the day you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A backup job that finishes green means the backup works, doesn't it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It means the job ran without reporting an error. It says nothing about whether the file can be replayed. A &lt;code&gt;pg_dump | gzip&lt;/code&gt; pipeline without &lt;code&gt;pipefail&lt;/code&gt; returns success on an empty file. A dump taken by a role that cannot read every table restores perfectly with half the data. A schedule that stopped firing leaves behind a dump that still restores. Only a restore, followed by counting what came out, distinguishes a backup that exists from a backup that restores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does &lt;code&gt;pg_dump&lt;/code&gt; not back up?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Roles and their passwords, the server-level configuration (&lt;code&gt;postgresql.conf&lt;/code&gt;, &lt;code&gt;pg_hba.conf&lt;/code&gt;), and the other databases on the same server. Roles are dumped separately with &lt;code&gt;pg_dumpall --globals-only&lt;/code&gt;. Extensions are in the dump as &lt;code&gt;CREATE EXTENSION&lt;/code&gt;, which means they must be installed on the server you restore onto, or the restore stops on that line.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://restoreproof.io/blog/back-up-postgresql-to-s3" rel="noopener noreferrer"&gt;restoreproof.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RestoreProof&lt;/strong&gt; runs this kind of restore test on a schedule, inside your own infrastructure, and signs a report proving the backup restores. Your data never leaves your servers. Free plan, no credit card: &lt;a href="https://app.restoreproof.io" rel="noopener noreferrer"&gt;https://app.restoreproof.io&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>backup</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cron jobs fail silently: monitor them with a dead man's switch</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Sat, 17 Oct 2026 10:48:16 +0000</pubDate>
      <link>https://dev.to/benjy33000/cron-jobs-fail-silently-monitor-them-with-a-dead-mans-switch-4kk3</link>
      <guid>https://dev.to/benjy33000/cron-jobs-fail-silently-monitor-them-with-a-dead-mans-switch-4kk3</guid>
      <description>&lt;p&gt;Cron jobs are the most silently dangerous services in your infrastructure. When a backup job fails, nothing alerts you, until the day you actually need that backup.&lt;/p&gt;

&lt;p&gt;The usual fix is a dead man's switch, also called a heartbeat: instead of watching for failures, you watch for the absence of success. This post explains the pattern, shows a bash script that reports its result correctly, and uses &lt;a href="https://maintenant.dev" rel="noopener noreferrer"&gt;Maintenant&lt;/a&gt; as the receiving end. Any heartbeat service (Healthchecks.io, Uptime Kuma push monitors, a self-written endpoint) works the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heartbeat principle
&lt;/h2&gt;

&lt;p&gt;After each run, your job sends a "ping" to a unique URL. The monitor knows how often that ping should arrive. If the ping does not come within the expected window plus a grace period, you get alerted.&lt;/p&gt;

&lt;p&gt;This catches the failures that a "notify on error" approach misses: the crontab entry that was deleted, the host that is down, the job that hangs forever, the script that crashes before it can send anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a heartbeat
&lt;/h2&gt;

&lt;p&gt;In Maintenant, a heartbeat is created through the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/api/v1/heartbeats &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Backup PostgreSQL",
    "interval_seconds": 86400,
    "grace_seconds": 3600
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns a unique UUID, used in the ping URL:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwmki1zi2w8cdtsc3m8s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwmki1zi2w8cdtsc3m8s.webp" alt="Heartbeat monitoring: cron jobs, durations, exit codes" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A backup script that always reports
&lt;/h2&gt;

&lt;p&gt;The obvious script has two bugs. With &lt;code&gt;set -e&lt;/code&gt;, a failing &lt;code&gt;pg_dump&lt;/code&gt; exits the script before the final ping is sent. And &lt;code&gt;$?&lt;/code&gt; after the start ping no longer holds the exit code of the backup, but that of the &lt;code&gt;curl&lt;/code&gt; that came after it.&lt;/p&gt;

&lt;p&gt;The fix is to capture the exit code in a variable right after the command, and to send the ping no matter what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail
&lt;span class="nv"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-heartbeat-uuid
&lt;span class="nv"&gt;PING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8080/ping/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UUID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PING&lt;/span&gt;&lt;span class="s2"&gt;/start"&lt;/span&gt;

&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
pg_dump &lt;span class="nt"&gt;-h&lt;/span&gt; postgres &lt;span class="nt"&gt;-U&lt;/span&gt; app mydb | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backups/mydb-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;.sql.gz &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;

curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PING&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$rc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There is no &lt;code&gt;set -e&lt;/code&gt;. A failure must not end the script before the ping.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;|| rc=$?&lt;/code&gt; captures the status of the pipeline and keeps the script going. &lt;code&gt;rc=0&lt;/code&gt; first means a success reports &lt;code&gt;0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set -o pipefail&lt;/code&gt; makes the pipeline fail if &lt;code&gt;pg_dump&lt;/code&gt; fails. Without it, the status is that of &lt;code&gt;gzip&lt;/code&gt;, which succeeds on empty input.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start and end signals
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/start&lt;/code&gt; ping is optional, but it lets the monitor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Measure the &lt;strong&gt;duration&lt;/strong&gt; of each run&lt;/li&gt;
&lt;li&gt;Detect jobs that &lt;strong&gt;start but never finish&lt;/strong&gt; (timeout, deadlock)&lt;/li&gt;
&lt;li&gt;Track duration trends over time&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Exit codes
&lt;/h2&gt;

&lt;p&gt;Appending the exit code to the ping URL (&lt;code&gt;/0&lt;/code&gt;, &lt;code&gt;/1&lt;/code&gt;, ...) tells the monitor whether the job succeeded. A non-zero code is recorded as a failure, even if the ping arrives on time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron jobs in Docker
&lt;/h2&gt;

&lt;p&gt;The same rule applies in a container: report the status whatever it is, so use &lt;code&gt;;&lt;/code&gt; rather than &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; between the job and the ping.&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;backup&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;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;-c "set -o pipefail;&lt;/span&gt;
          &lt;span class="s"&gt;pg_dump -h postgres -U app mydb | gzip &amp;gt; /backups/daily.sql.gz;&lt;/span&gt;
          &lt;span class="s"&gt;wget -qO- http://maintenant:8080/ping/YOUR-UUID/$$?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$$?&lt;/code&gt; is how Compose escapes a literal &lt;code&gt;$?&lt;/code&gt;. The image must contain &lt;code&gt;wget&lt;/code&gt; (or replace it with &lt;code&gt;curl&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One heartbeat per critical job&lt;/strong&gt;: backup, cleanup, certificate renewal, sync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generous grace period&lt;/strong&gt;: if your backup usually takes 5 minutes, set a 30-minute grace period&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use start and end signals&lt;/strong&gt; to detect stuck jobs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass the exit code&lt;/strong&gt;: a job that "succeeds" with an error code is not a success&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://maintenant.dev/blog/monitoring-cron-jobs/" rel="noopener noreferrer"&gt;maintenant.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenant&lt;/strong&gt; is an open-source, self-hosted monitoring tool for Docker and Kubernetes: containers, endpoints, cron heartbeats, TLS certificates and alerts, in one container that discovers your stack on its own. Install it in a minute: &lt;a href="https://maintenant.dev" rel="noopener noreferrer"&gt;https://maintenant.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This project was funded by the French Government as part of the France 2030 plan operated by Bpifrance.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo10fjt141n9rw7j6sge.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo10fjt141n9rw7j6sge.png" alt="France 2030 and Cap Digital" width="174" height="56"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>21 questions to ask a SaaS boilerplate before you pay for it</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:09:25 +0000</pubDate>
      <link>https://dev.to/benjy33000/21-questions-to-ask-a-saas-boilerplate-before-you-pay-for-it-12im</link>
      <guid>https://dev.to/benjy33000/21-questions-to-ask-a-saas-boilerplate-before-you-pay-for-it-12im</guid>
      <description>&lt;p&gt;Feature lists are marketing. These are the twenty-one questions that separate a&lt;br&gt;
boilerplate you can build a business on from a demo with a login screen. They&lt;br&gt;
work on any kit — take them to whichever vendor you are evaluating, including&lt;br&gt;
this one.&lt;/p&gt;

&lt;p&gt;Each question is followed by GoVueKit's answer, so the comparison is concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. How are passwords hashed, with what parameters?&lt;/strong&gt;&lt;br&gt;
Bcrypt with default cost is acceptable; anything home-made is not. &lt;em&gt;GoVueKit:&lt;br&gt;
argon2id, 64 MiB memory, 3 iterations, per-password salt.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Do signup, login and password reset reveal whether an account exists?&lt;/strong&gt;&lt;br&gt;
Different messages or timings are account enumeration. &lt;em&gt;GoVueKit: neutral&lt;br&gt;
responses on all three, and a test that asserts it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Where do sessions live, and what happens on password change?&lt;/strong&gt;&lt;br&gt;
Stateless JWTs that cannot be revoked are a liability for a product with&lt;br&gt;
billing. &lt;em&gt;GoVueKit: server-side sessions in the database; a password change&lt;br&gt;
terminates every other session.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Is there two-factor authentication, and does it cover every sign-in&lt;br&gt;
path?&lt;/strong&gt; 2FA that guards the password form but not the magic link is theatre.&lt;br&gt;
&lt;em&gt;GoVueKit: TOTP with ten single-use recovery codes, enforced on password,&lt;br&gt;
magic link and OAuth sign-in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. What happens when an OAuth email matches an existing account?&lt;/strong&gt;&lt;br&gt;
Silent merging is an account-takeover vector. &lt;em&gt;GoVueKit: no implicit merge; the&lt;br&gt;
user must sign in and link the provider explicitly.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenancy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;6. Where is the tenant boundary enforced?&lt;/strong&gt;&lt;br&gt;
"In the service layer" means "wherever someone remembered". &lt;em&gt;GoVueKit: one&lt;br&gt;
middleware resolves the organization against the caller's memberships, and&lt;br&gt;
every business query filters on &lt;code&gt;organization_id&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. What does a request for another tenant's resource return?&lt;/strong&gt;&lt;br&gt;
403 confirms the resource exists. &lt;em&gt;GoVueKit: 404, indistinguishable from a&lt;br&gt;
resource that never existed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Is cross-tenant isolation tested end to end?&lt;/strong&gt;&lt;br&gt;
Ask to see the test. &lt;em&gt;GoVueKit: a Playwright journey creates two&lt;br&gt;
organizations and asserts the second cannot read the first — plus integration&lt;br&gt;
tests per endpoint.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Can roles be bypassed from the frontend?&lt;/strong&gt;&lt;br&gt;
Every kit hides buttons; the question is what the server does. &lt;em&gt;GoVueKit: RBAC&lt;br&gt;
is server-side middleware, frontend checks are explicitly cosmetic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. What protects the last owner of an organization?&lt;/strong&gt;&lt;br&gt;
Products lose ownership when the last owner leaves. &lt;em&gt;GoVueKit: the last owner&lt;br&gt;
cannot be demoted, removed, or leave without transferring ownership.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Billing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;11. Are webhooks idempotent, and how?&lt;/strong&gt;&lt;br&gt;
"We check if we already processed it" is not enough if the check and the write&lt;br&gt;
are in different transactions. &lt;em&gt;GoVueKit: the event id is inserted with a&lt;br&gt;
primary key and the business write commits in the same transaction —&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/idempotent-billing-webhooks" rel="noopener noreferrer"&gt;the full pattern&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Is payload data trusted?&lt;/strong&gt;&lt;br&gt;
A valid signature proves the sender, not that the state is current. &lt;em&gt;GoVueKit:&lt;br&gt;
every object is re-fetched from the provider API by id; nothing beyond the id&lt;br&gt;
is trusted.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Who owns the subscription — the user or the organization?&lt;/strong&gt;&lt;br&gt;
Per-user billing on a team product is a migration you will do later, painfully.&lt;br&gt;
&lt;em&gt;GoVueKit: the organization owns it, and solo mode makes it per-user when that&lt;br&gt;
is what you want.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. What happens when money leaves the account, and what would a second&lt;br&gt;
provider cost?&lt;/strong&gt; Most kits handle payment and stop there; refunds and&lt;br&gt;
chargebacks arrive anyway. &lt;em&gt;GoVueKit: Stripe only — &lt;code&gt;charge.refunded&lt;/code&gt; stamps&lt;br&gt;
the purchase rather than deleting it (the row reconciles with Stripe), and&lt;br&gt;
&lt;code&gt;charge.dispute.created&lt;/code&gt; is logged at Error level so it reaches your error&lt;br&gt;
reporter. Stripe sits behind a five-method &lt;code&gt;billing.Provider&lt;/code&gt; interface, so&lt;br&gt;
another provider is a package to write, not a rewrite — but it is an&lt;br&gt;
integration you would write, not a switch you flip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15. Where do prices live?&lt;/strong&gt;&lt;br&gt;
An admin UI that can disagree with what the payment provider charges is a bug&lt;br&gt;
generator. &lt;em&gt;GoVueKit: plans are configuration, served to both the pricing page&lt;br&gt;
and the billing screen; the amount charged always comes from the provider.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Operations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;16. What must be running in production besides the app and the database?&lt;/strong&gt;&lt;br&gt;
Every additional service is an on-call surface. &lt;em&gt;GoVueKit: nothing. The job&lt;br&gt;
queue is a table, sessions are rows, files can be on disk.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;17. What happens to queued emails when you redeploy?&lt;/strong&gt;&lt;br&gt;
In-memory queues lose them. &lt;em&gt;GoVueKit: jobs are durable rows, claimed and&lt;br&gt;
retried after a restart.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;18. How are errors reported, and can you swap the reporter?&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;GoVueKit: every Error-level log record reaches a pluggable reporter;&lt;br&gt;
&lt;code&gt;SENTRY_DSN&lt;/code&gt; enables the bundled one, and the interface is one method.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;19. What does the deployment story look like, in commands?&lt;/strong&gt;&lt;br&gt;
If the answer is a platform button, ask what self-hosting takes. &lt;em&gt;GoVueKit:&lt;br&gt;
one binary, or &lt;code&gt;docker compose -f docker-compose.prod.yml up -d --build&lt;/code&gt; with&lt;br&gt;
Traefik and Let's Encrypt included.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The purchase itself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;20. What exactly does the licence allow, and forbid?&lt;/strong&gt;&lt;br&gt;
The clauses that matter: unlimited end products, client work, whether updates&lt;br&gt;
are perpetual, and the redistribution restriction. &lt;em&gt;GoVueKit: perpetual,&lt;br&gt;
unlimited products including client work, lifetime updates; you may not&lt;br&gt;
republish the source as a competing kit.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;21. How are updates delivered, and how do you merge them?&lt;/strong&gt;&lt;br&gt;
A zip file is a fork you maintain alone. &lt;em&gt;GoVueKit: a private repository you&lt;br&gt;
are invited to, with atomic conventional commits and a changelog — you pull,&lt;br&gt;
read the diff, merge.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bonus question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can you read the code before buying?&lt;/strong&gt;&lt;br&gt;
Most kits publish something: a demo repository, documentation, code excerpts,&lt;br&gt;
a video walkthrough. A vendor that shows nothing is asking you to buy a&lt;br&gt;
promise. GoVueKit publishes excerpts on the &lt;a href="https://govuekit.dev/#code" rel="noopener noreferrer"&gt;landing page&lt;/a&gt; and its&lt;br&gt;
guides in the open at &lt;code&gt;docs.govuekit.dev&lt;/code&gt; — including the ten-minute tour that&lt;br&gt;
follows one request through every layer, from the chi route to the SQL and&lt;br&gt;
back. The repository adds &lt;code&gt;DECISIONS.md&lt;/code&gt;, which is the file to read second:&lt;br&gt;
every non-obvious call, with its reasoning, including the features that were&lt;br&gt;
built and then cut. And the site you are reading is served by the product&lt;br&gt;
itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions worth asking about what is &lt;em&gt;missing&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;A serious kit says no to things. Ask what a vendor deliberately left out and&lt;br&gt;
why; "everything is included" is the answer of a product nobody has had to&lt;br&gt;
maintain. &lt;em&gt;GoVueKit: no code generator, no example business module, no second&lt;br&gt;
payment provider, no passkeys, no feature flags — several of those existed and&lt;br&gt;
were removed, and &lt;code&gt;DECISIONS.md&lt;/code&gt; explains each cut. If your evaluation is&lt;br&gt;
scored on feature count, that is a column GoVueKit loses on purpose.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use this list
&lt;/h2&gt;

&lt;p&gt;Do not send all twenty-one questions to a vendor — you will get a marketing&lt;br&gt;
reply. Pick the five that map to your risk: if you sell to teams, ask 6, 7, 8;&lt;br&gt;
if you charge money, ask 11, 12, 14; if you have no ops team, ask 16, 17, 19.&lt;/p&gt;

&lt;p&gt;And ask for the test, not the answer. "Where is the test that proves that?" is&lt;br&gt;
the single most useful sentence in this entire process.&lt;/p&gt;

&lt;p&gt;Want the comparison by architecture rather than by feature? Read&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/saas-boilerplate-comparison-2026" rel="noopener noreferrer"&gt;choosing a SaaS boilerplate in 2026&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/saas-boilerplate-evaluation-checklist" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Self-hosted SaaS boilerplate: what \"self-hosted\" has to mean, and a checklist</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 13 Oct 2026 07:11:19 +0000</pubDate>
      <link>https://dev.to/benjy33000/self-hosted-saas-boilerplate-what-self-hosted-has-to-mean-and-a-checklist-56bm</link>
      <guid>https://dev.to/benjy33000/self-hosted-saas-boilerplate-what-self-hosted-has-to-mean-and-a-checklist-56bm</guid>
      <description>&lt;p&gt;"Self-hosted" has become a checkbox on boilerplate landing pages, and the&lt;br&gt;
checkbox is usually true in the narrowest sense: yes, the Node process can&lt;br&gt;
run on your VPS. Then you read the &lt;code&gt;.env.example&lt;/code&gt; and find the auth&lt;br&gt;
provider's API key, the managed database URL, the queue service token, the&lt;br&gt;
email API key and the file-storage bucket. The process is yours; the&lt;br&gt;
product is theirs. This article proposes a definition strict enough to be&lt;br&gt;
useful, and turns it into questions you can ask any kit — including ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  A definition
&lt;/h2&gt;

&lt;p&gt;A SaaS foundation is self-hosted when &lt;strong&gt;the product keeps working, for all&lt;br&gt;
its users, with nothing but the servers you operate and the one payment&lt;br&gt;
processor you cannot avoid.&lt;/strong&gt; Everything else is optional or replaceable&lt;br&gt;
with something you run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sign-in works with your database alone. External identity providers are
a feature you add, not a dependency you start with.&lt;/li&gt;
&lt;li&gt;Email goes out through any SMTP server, including your own; a hosted
email API is a configuration, not a requirement.&lt;/li&gt;
&lt;li&gt;Background jobs run inside the application, backed by your database,
and survive a restart.&lt;/li&gt;
&lt;li&gt;Files go to a local directory or to any S3-compatible service you
choose, including one you run.&lt;/li&gt;
&lt;li&gt;Analytics, error reporting and monitoring are opt-in and can point at
self-hosted tools.&lt;/li&gt;
&lt;li&gt;The payment processor is the exception, and its failure mode is
bounded: with no key configured, the application runs, and billing is
simply off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By that definition most "self-hostable" kits are not. That is not an&lt;br&gt;
accusation; it is a consequence of building on a BaaS. It is worth knowing&lt;br&gt;
before you sign a data-processing agreement that lists sub-processors you&lt;br&gt;
did not choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters, concretely
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bill.&lt;/strong&gt; A rented stack is five invoices with five floors. A binary on&lt;br&gt;
a server is the server. We priced it in&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/the-rented-stack-bill" rel="noopener noreferrer"&gt;The rented stack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The questionnaire.&lt;/strong&gt; The first enterprise customer sends a security&lt;br&gt;
questionnaire. "Where is the data, who are the sub-processors, how do you&lt;br&gt;
delete a user?" With a self-hosted foundation the answers are one country,&lt;br&gt;
three names and a button. See &lt;a href="https://govuekit.dev/blog/eu-hosting-gdpr-saas" rel="noopener noreferrer"&gt;Keeping a SaaS in the EU&lt;/a&gt;&lt;br&gt;
for what self-hosting settles and what it does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exit.&lt;/strong&gt; When a vendor changes pricing or terms, a self-hosted product&lt;br&gt;
moves by copying a binary and a database dump. A product built on managed&lt;br&gt;
auth moves by re-implementing auth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Ask these of any kit. The honest answers are usually in the &lt;code&gt;.env.example&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete every third-party key from the configuration. Does it boot,
can a user sign up, sign in, reset a password?&lt;/strong&gt; If sign-in needs an
external provider to exist, it is not self-hosted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which database, and can I run it myself?&lt;/strong&gt; PostgreSQL on your server
counts. A vendor's "serverless Postgres" with a connection string does
not, unless the kit also runs against a plain instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where do background jobs run?&lt;/strong&gt; In-process with a database table:
yes. A hosted queue with a token: no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How does email leave?&lt;/strong&gt; SMTP to any host: yes. Only through one API
vendor: no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where do uploads go?&lt;/strong&gt; Local disk or any S3-compatible endpoint: yes.
One vendor's bucket API: no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens with no payment key?&lt;/strong&gt; The app should run and hide
billing. A crash, or a checkout button that errors, means the
unconfigured path was never tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What runs in production?&lt;/strong&gt; One process you can list is better than a
platform-specific build output. Count the runtimes: Node, PHP, Redis,
workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can I read all of it?&lt;/strong&gt; Lines of code you will own, not features
advertised. If the answer is "it's a monorepo, you don't need to read
the packages", you are renting with extra steps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is the licence perpetual, and does it cover client work?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the repository come with a way to run everything locally,
including the external pieces (identity provider, S3, mail, payment
sandbox)?&lt;/strong&gt; If the vendor's own demo needs their cloud, so will yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How GoVueKit answers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Boots and signs users up with a database file and nothing else: argon2id
passwords, verification, reset, magic links, TOTP, session revocation.
Google, GitHub, Microsoft and any OIDC provider are switched on by
adding keys; the unconfigured path is tested in CI.&lt;/li&gt;
&lt;li&gt;PostgreSQL &lt;strong&gt;and&lt;/strong&gt; SQLite, same migrations, same generated queries,
both engines in the test matrix. &lt;code&gt;DB_DRIVER&lt;/code&gt; decides.&lt;/li&gt;
&lt;li&gt;Jobs are rows in your database, run in-process, retried, surviving
redeploys.&lt;/li&gt;
&lt;li&gt;SMTP by default; Resend is an option behind the same &lt;code&gt;Mailer&lt;/code&gt;
interface.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;STORAGE=disk&lt;/code&gt; or &lt;code&gt;STORAGE=s3&lt;/code&gt; against any endpoint (the labs uses
MinIO).&lt;/li&gt;
&lt;li&gt;No Stripe key means billing is off and the app is otherwise complete;
the tests cover that configuration because it is the one most
deployments run.&lt;/li&gt;
&lt;li&gt;One static binary, ~27 MB, the Vue app embedded. Traefik for TLS if you
want automatic certificates. Nothing else.&lt;/li&gt;
&lt;li&gt;About 11 000 lines of Go, 19 packages, one migration file, and a
&lt;code&gt;DECISIONS.md&lt;/code&gt; that explains the non-obvious choices — public before
you buy, at &lt;a href="https://github.com/kOlapsis/govuekit" rel="noopener noreferrer"&gt;github.com/kOlapsis/govuekit&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;€199 once, perpetual, lifetime updates of the major version, unlimited
end products including client work.&lt;/li&gt;
&lt;li&gt;One compose file, no account:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;   &lt;span class="c"&gt;# app + PostgreSQL + Dex (OIDC) + MinIO (S3) + stripe-mock + Mailpit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tenth question is the one to start with. A kit that can boot its whole&lt;br&gt;
world on your laptop can boot it on your server; one that cannot has&lt;br&gt;
already told you where it really lives.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/self-hosted-saas-boilerplate-checklist" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>saas</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Embedding a Vue 3 app in a Go binary with go:embed — and keeping SEO without SSR</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 08 Oct 2026 07:07:04 +0000</pubDate>
      <link>https://dev.to/benjy33000/embedding-a-vue-3-app-in-a-go-binary-with-goembed-and-keeping-seo-without-ssr-1p2m</link>
      <guid>https://dev.to/benjy33000/embedding-a-vue-3-app-in-a-go-binary-with-goembed-and-keeping-seo-without-ssr-1p2m</guid>
      <description>&lt;p&gt;"One binary" is easy to say and easy to get half right. Serving &lt;code&gt;dist/&lt;/code&gt; from&lt;br&gt;
Go takes six lines; serving it so that deep links work, real 404s stay 404s,&lt;br&gt;
crawlers see content, canonicals point at the right origin and the build&lt;br&gt;
stays deployment-agnostic takes a few decisions. This is how GoVueKit does&lt;br&gt;
it, with the code, and the reasons.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Embed the build
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// web/embed.go&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;web&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"embed"&lt;/span&gt;

&lt;span class="c"&gt;//go:embed all:dist&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;Dist&lt;/span&gt; &lt;span class="n"&gt;embed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two details matter. &lt;code&gt;all:&lt;/code&gt; includes dotfiles, so a &lt;code&gt;.gitkeep&lt;/code&gt; keeps &lt;code&gt;dist/&lt;/code&gt;&lt;br&gt;
present in a fresh clone and the embed directive never fails to match. And&lt;br&gt;
the frontend is built &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;go build&lt;/code&gt;, never at runtime: the&lt;br&gt;
&lt;code&gt;Makefile&lt;/code&gt; runs &lt;code&gt;npm ci &amp;amp;&amp;amp; vite build&lt;/code&gt; first, the Dockerfile has a Node stage&lt;br&gt;
whose only output is &lt;code&gt;web/dist&lt;/code&gt;, and the final image is &lt;code&gt;FROM scratch&lt;/code&gt; with&lt;br&gt;
the binary in it. Node exists at build time and nowhere else.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Serve the SPA without lying about 404s
&lt;/h2&gt;

&lt;p&gt;The naïve handler serves &lt;code&gt;index.html&lt;/code&gt; for every unknown path. That is also&lt;br&gt;
how you end up with &lt;code&gt;/blog/does-not-exist&lt;/code&gt; returning a 200 with an empty&lt;br&gt;
shell, which search engines index as duplicate content. GoVueKit mounts&lt;br&gt;
routes in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;/api/...&lt;/code&gt; — JSON handlers; an unknown API path answers a JSON 404.&lt;/li&gt;
&lt;li&gt;Server-rendered pages that must be real files to crawlers: &lt;code&gt;/blog&lt;/code&gt;,
&lt;code&gt;/blog/{slug}&lt;/code&gt;, &lt;code&gt;/blog/feed.xml&lt;/code&gt;, &lt;code&gt;/sitemap.xml&lt;/code&gt;, &lt;code&gt;/robots.txt&lt;/code&gt;. An
unknown slug renders the blog's own 404 page with status 404.&lt;/li&gt;
&lt;li&gt;Everything else — the SPA handler: a static asset if the path exists in
&lt;code&gt;dist/&lt;/code&gt;, the prerendered HTML if the route was captured (below), the
shell otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shell is the fallback for &lt;strong&gt;application&lt;/strong&gt; routes (&lt;code&gt;/dashboard&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;/orgs/…&lt;/code&gt;), where the router owns the URL and a guest gets bounced to&lt;br&gt;
&lt;code&gt;/login&lt;/code&gt;. Nothing indexable relies on it.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Prerender the public pages at build time
&lt;/h2&gt;

&lt;p&gt;SSR would solve SEO and cost a Node process in production, which defeats&lt;br&gt;
the binary. Prerendering solves it at build time instead: the public pages&lt;br&gt;
are static enough (landing, pricing, legal, contact) that the HTML a crawler&lt;br&gt;
needs is the HTML an anonymous first visit renders.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;web/scripts/prerender.mjs&lt;/code&gt; runs after &lt;code&gt;vite build&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it serves &lt;code&gt;dist/&lt;/code&gt; through &lt;code&gt;vite preview&lt;/code&gt; and drives each route from
&lt;code&gt;prerender.routes.json&lt;/code&gt; (English and French twins) with the Playwright
Chromium;&lt;/li&gt;
&lt;li&gt;every request to another origin is aborted — an analytics script must not
record a build as a visit, and the build must not depend on a third-party
host being up;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/**&lt;/code&gt; is stubbed (&lt;code&gt;/api/auth/me&lt;/code&gt; → 401, anything else → 404), so the
captured page is exactly what an anonymous visitor sees;&lt;/li&gt;
&lt;li&gt;it fails loudly: a route that rendered under 500 characters, or that has
no canonical tag, breaks the build instead of embedding an empty shell;&lt;/li&gt;
&lt;li&gt;the captured HTML lands in &lt;code&gt;dist/prerendered/&amp;lt;route&amp;gt;/index.html&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Go server loads that directory at startup into a map from route to&lt;br&gt;
bytes. A route present in the map is served before the shell; a build&lt;br&gt;
without the directory gets an empty map and behaves as a plain SPA. The&lt;br&gt;
whole feature is one build flag (&lt;code&gt;PRERENDER=1&lt;/code&gt; in the Docker build).&lt;/p&gt;
&lt;h2&gt;
  
  
  4. The origin problem, and the placeholder
&lt;/h2&gt;

&lt;p&gt;Canonicals and &lt;code&gt;hreflang&lt;/code&gt; links are absolute URLs. At build time the app&lt;br&gt;
runs on &lt;code&gt;http://localhost:4173&lt;/code&gt;; in production it runs on whatever&lt;br&gt;
&lt;code&gt;BASE_URL&lt;/code&gt; the operator configured. Baking the production origin into the&lt;br&gt;
build would make the image deployment-specific, which is exactly what a&lt;br&gt;
signed, published image must not be.&lt;/p&gt;

&lt;p&gt;So the prerenderer rewrites its own origin to a placeholder,&lt;br&gt;
&lt;code&gt;__BASE_URL__&lt;/code&gt;, and the server substitutes it &lt;strong&gt;once at startup&lt;/strong&gt; while&lt;br&gt;
loading the pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"__BASE_URL__"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same image serves &lt;code&gt;https://govuekit.dev&lt;/code&gt; and &lt;code&gt;https://demo.example.com&lt;/code&gt;&lt;br&gt;
with correct canonicals on both. The e2e suite asserts that no&lt;br&gt;
&lt;code&gt;__BASE_URL__&lt;/code&gt; survives in a served page.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Hydration, hreflang and the sitemap
&lt;/h2&gt;

&lt;p&gt;The Vue app mounts on top of the prerendered DOM, so a browser gets content&lt;br&gt;
first and interactivity a moment later; the head tags the app manages&lt;br&gt;
(title, description, canonical, the &lt;code&gt;hreflang&lt;/code&gt; pair for &lt;code&gt;/x&lt;/code&gt; and &lt;code&gt;/fr/x&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;x-default&lt;/code&gt; on English) are written by a small &lt;code&gt;useSeoHead&lt;/code&gt; composable and&lt;br&gt;
captured with the page. The sitemap is derived from the set of prerendered&lt;br&gt;
routes plus the blog posts with their &lt;code&gt;lastmod&lt;/code&gt;, so a route added to&lt;br&gt;
&lt;code&gt;prerender.routes.json&lt;/code&gt; is indexed without touching Go.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. What this does not do
&lt;/h2&gt;

&lt;p&gt;It does not render per-user or per-request content for crawlers: the&lt;br&gt;
dashboard is not prerendered, and should not be. It does not replace a&lt;br&gt;
content-heavy marketing site with thousands of pages; for that, a static&lt;br&gt;
site generator next to the binary is the right tool. And it does not&lt;br&gt;
prerender the blog, because the blog is already server-rendered by Go from&lt;br&gt;
embedded markdown — the other half of the "content in the binary" story.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;GoVueKit's own sales site is built this way: &lt;code&gt;curl -sI https://govuekit.dev/fr&lt;/code&gt;&lt;br&gt;
shows a real HTML document with a canonical and two &lt;code&gt;hreflang&lt;/code&gt; links before&lt;br&gt;
any JavaScript runs. The mechanism, the knobs and the reasons live in&lt;br&gt;
&lt;code&gt;docs/SEO.md&lt;/code&gt; of the kit, and the whole product runs locally from one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/embed-vue-app-in-go-binary-seo" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>vue</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Idempotent billing webhooks: exactly-once fulfilment with Stripe</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 06 Oct 2026 07:05:40 +0000</pubDate>
      <link>https://dev.to/benjy33000/idempotent-billing-webhooks-exactly-once-fulfilment-with-stripe-44jb</link>
      <guid>https://dev.to/benjy33000/idempotent-billing-webhooks-exactly-once-fulfilment-with-stripe-44jb</guid>
      <description>&lt;p&gt;Payment providers deliver webhooks &lt;em&gt;at least once&lt;/em&gt;. That is not a bug in their&lt;br&gt;
systems, it is the only guarantee a network can offer: if your endpoint is&lt;br&gt;
slow, times out, or answers 500 after committing, the event comes back. Twice.&lt;br&gt;
Sometimes days later.&lt;/p&gt;

&lt;p&gt;Everything that follows exists to make the second delivery boring.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three ways this goes wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Double fulfilment.&lt;/strong&gt; The event arrives twice, you grant credits twice. The&lt;br&gt;
customer notices only when it goes the other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lost fulfilment.&lt;/strong&gt; You verify the signature, start work, and time out. The&lt;br&gt;
provider retries, your handler crashes on a half-written state, and the&lt;br&gt;
subscription never activates. The customer paid; your database disagrees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the payload.&lt;/strong&gt; The webhook body says the subscription is active.&lt;br&gt;
Between the event being generated and your handler running, the payment was&lt;br&gt;
disputed and the subscription cancelled. A valid signature proves who sent the&lt;br&gt;
message, not that the world still looks like that.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Four rules, in order.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Read the raw body and verify the signature
&lt;/h3&gt;

&lt;p&gt;The signature is computed over the exact bytes sent. Any middleware that&lt;br&gt;
parses and re-serialises JSON before you check it will break verification, so&lt;br&gt;
the webhook route must read the body before anything touches it.&lt;/p&gt;

&lt;p&gt;Stripe's tolerance window (300 seconds by default in GoVueKit) also matters:&lt;br&gt;
it is what stops an attacker from replaying a captured, still-valid request&lt;br&gt;
next month.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Answer fast, work after
&lt;/h3&gt;

&lt;p&gt;Providers treat a slow endpoint as a failed one. The handler acknowledges as&lt;br&gt;
soon as the event is durably recorded, and the actual work happens in the&lt;br&gt;
background worker — which, because the queue is a database table, survives the&lt;br&gt;
redeploy that happens three seconds later.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Deduplicate with a primary key, in the same transaction as the write
&lt;/h3&gt;

&lt;p&gt;This is the part that people get almost right. The failure mode of "check if&lt;br&gt;
processed, then process, then mark processed" is a crash between steps two and&lt;br&gt;
three — or two deliveries racing.&lt;/p&gt;

&lt;p&gt;The fix is not a lock. It is a single transaction:&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="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;   &lt;span class="c1"&gt;-- primary key&lt;/span&gt;
  &lt;span class="c1"&gt;-- unique violation → this event is already done, ROLLBACK and answer 200&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either both happen or neither does. A duplicate delivery hits the primary key&lt;br&gt;
and becomes a no-op. A crash mid-way rolls back and the provider's retry&lt;br&gt;
replays it cleanly. There is no window in which the event is marked done but&lt;br&gt;
the business write is missing.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Re-fetch the object by id; never trust the payload
&lt;/h3&gt;

&lt;p&gt;The handler takes exactly one thing from the payload: the object id. Then it&lt;br&gt;
asks the provider's API what that object looks like &lt;em&gt;now&lt;/em&gt;, and writes that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event → id → provider.Resolve(ctx, ev) → authoritative state → write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This costs one API call per event and removes a whole class of race&lt;br&gt;
conditions. It also means a replayed six-day-old event cannot resurrect a&lt;br&gt;
cancelled subscription: the re-fetch returns the current state.&lt;/p&gt;

&lt;p&gt;It has a second benefit that only shows up in production: because nothing is&lt;br&gt;
read from the payload beyond the id, the API release train stamped on the&lt;br&gt;
envelope stops mattering. Verification checks the signature and the timestamp,&lt;br&gt;
deliberately not the version, and every re-fetch sends the SDK's own&lt;br&gt;
&lt;code&gt;Stripe-Version&lt;/code&gt; — so a dashboard that upgrades under you does not turn every&lt;br&gt;
delivery into a 400.&lt;/p&gt;

&lt;p&gt;The same rule forbids fetching URLs found in a payload — a webhook body is&lt;br&gt;
attacker-influenced input, and following its links is how SSRF starts. Any&lt;br&gt;
outbound call in GoVueKit that is not to a hardcoded host goes through the&lt;br&gt;
hardened &lt;code&gt;safehttp&lt;/code&gt; client, which validates the IP at dial time and refuses&lt;br&gt;
private ranges.&lt;/p&gt;
&lt;h2&gt;
  
  
  One provider, behind a seam
&lt;/h2&gt;

&lt;p&gt;GoVueKit sells through Stripe. A store has one merchant of record, so a second&lt;br&gt;
provider is not a feature the kit switches between — but it is a package&lt;br&gt;
away rather than a rewrite, because the billing page, the admin metrics and&lt;br&gt;
the subscription row talk to an interface, not to Stripe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Provider&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Checkout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actorEmail&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actorEmail&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;VerifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five methods. If you need Paddle, Lemon Squeezy or a local PSP, that is what&lt;br&gt;
you implement, and nothing above the seam changes. Be honest with yourself&lt;br&gt;
about the size of that job: it is a real integration, not a config switch, and&lt;br&gt;
the kit does not ship it.&lt;/p&gt;

&lt;p&gt;What the seam does buy you is that the guarantee travels. The shape of the&lt;br&gt;
dedup — one row, one primary key, one transaction — does not depend on what&lt;br&gt;
the key is made of. Stripe hands you a unique event id; providers that do not&lt;br&gt;
force you to build the key from the pair (event name, object id) instead. The&lt;br&gt;
pattern survives the substitution, which is the whole point of writing it&lt;br&gt;
against an interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money leaving the account counts too
&lt;/h2&gt;

&lt;p&gt;Eight event types cover money arriving. Two more cover it leaving, and they&lt;br&gt;
are the ones that get forgotten:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;charge.refunded&lt;/code&gt; &lt;strong&gt;stamps&lt;/strong&gt; the purchase rather than deleting it. The row
stays because it is what reconciles with the provider's own records.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charge.dispute.created&lt;/code&gt; is logged at Error level, so it reaches your error
reporter rather than a log file nobody greps. A chargeback has a deadline;
it should behave like an incident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three &lt;code&gt;checkout.session.*&lt;/code&gt; events matter together, not individually. A&lt;br&gt;
card pays inside the checkout, so &lt;code&gt;completed&lt;/code&gt; already reports&lt;br&gt;
&lt;code&gt;payment_status=paid&lt;/code&gt;. A deferred method — SEPA direct debit, which Stripe&lt;br&gt;
enables readily on a EUR checkout — completes &lt;strong&gt;unpaid&lt;/strong&gt; and settles days&lt;br&gt;
later as &lt;code&gt;async_payment_succeeded&lt;/code&gt;, or bounces as &lt;code&gt;async_payment_failed&lt;/code&gt;.&lt;br&gt;
Subscribe to &lt;code&gt;completed&lt;/code&gt; alone and those sales are never fulfilled, silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test
&lt;/h2&gt;

&lt;p&gt;Three tests, and they are quick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bad signature → 400, nothing written.&lt;/strong&gt; The most common configuration
error in production is a mismatched signing secret; you want it loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same event delivered twice → one business write.&lt;/strong&gt; Send the identical
payload twice and assert the subscription row changed once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown event type → 200.&lt;/strong&gt; Providers add event types; your endpoint must
ignore them without erroring, or their dashboard will start disabling your
webhook.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;GoVueKit ships all three as integration tests, plus a Playwright journey that&lt;br&gt;
posts a signed duplicate through the real binary. The unconfigured path is&lt;br&gt;
tested too: with no Stripe key — which is the default, and what most&lt;br&gt;
deployments run — the endpoints answer a deliberate 501 and nothing is&lt;br&gt;
written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Point Stripe at &lt;code&gt;/api/billing/webhook&lt;/code&gt;, and subscribe the eight events the
README lists rather than "all events": an endpoint that receives everything
is an endpoint whose logs tell you nothing.&lt;/li&gt;
&lt;li&gt;Local testing: &lt;code&gt;stripe listen --forward-to localhost:8080/api/billing/webhook&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Log the event id on every branch. When a customer says "I paid and nothing
happened", the id is what turns a conversation into a query.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;processed_events&lt;/code&gt; forever, or at least well past the provider's retry
window. It is small, and it is your proof.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule underneath all of this is the same one that governs&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/row-level-multi-tenancy-go" rel="noopener noreferrer"&gt;tenancy&lt;/a&gt;: make the safe path the only&lt;br&gt;
convenient path, then write the test that fails when someone finds a shortcut.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://govuekit.dev/#inside" rel="noopener noreferrer"&gt;See how billing fits in the kit&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/idempotent-billing-webhooks" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>go</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Row-level multi-tenancy in Go: the rule, the middleware and the test that proves it</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:07:03 +0000</pubDate>
      <link>https://dev.to/benjy33000/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves-it-3gkb</link>
      <guid>https://dev.to/benjy33000/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves-it-3gkb</guid>
      <description>&lt;p&gt;Multi-tenancy has three common implementations: a database per tenant, a&lt;br&gt;
schema per tenant, and a column on every row. For a SaaS that starts small and&lt;br&gt;
must stay operable by one person, the column wins — provided the rule is&lt;br&gt;
enforced in one place rather than remembered in fifty.&lt;/p&gt;

&lt;p&gt;Here is the whole design, as implemented in GoVueKit, and the reasoning behind&lt;br&gt;
each choice.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Every business table has an &lt;code&gt;organization_id&lt;/code&gt;. Every query filters on it.&lt;br&gt;
The value comes from the request context, never from the client.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three sentences, and they are the entire security model for tenancy. The&lt;br&gt;
implementation exists to make violating them awkward.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: the route carries the tenant
&lt;/h2&gt;

&lt;p&gt;Tenant-scoped routes live under &lt;code&gt;/api/orgs/{orgID}/…&lt;/code&gt;. That is deliberate: the&lt;br&gt;
tenant is part of the URL, so it is visible in logs, in tests, and in the&lt;br&gt;
router itself. The alternative — an implicit "current organization" in the&lt;br&gt;
session — makes every request depend on hidden state, and makes tab-switching&lt;br&gt;
users a bug report.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: one middleware resolves membership
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Context loads the organization and the caller's membership. Non-members&lt;/span&gt;
&lt;span class="c"&gt;// get a 404 — indistinguishable from a nonexistent org, so org IDs never&lt;/span&gt;
&lt;span class="c"&gt;// leak across tenants. Must be mounted after RequireAuth.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;UserFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"authentication required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;orgID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orgID"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrgMember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrgMemberParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;OrganizationID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orgID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNoRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// ... load the organization, put {Org, Role} in the context&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The membership lookup &lt;em&gt;is&lt;/em&gt; the authorisation check. If the caller is not a&lt;br&gt;
member, the request dies here — before any handler, before any query, before&lt;br&gt;
any chance to forget.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: 404, not 403
&lt;/h2&gt;

&lt;p&gt;A 403 says: this organization exists, and you are not in it. That is an&lt;br&gt;
information leak, and a useful one for an attacker enumerating ids. A 404 says&lt;br&gt;
nothing at all.&lt;/p&gt;

&lt;p&gt;The same reasoning applies inside a tenant: a resource that belongs to another&lt;br&gt;
organization is not "forbidden", it is &lt;em&gt;not found&lt;/em&gt;, because from the caller's&lt;br&gt;
point of view it does not exist.&lt;/p&gt;

&lt;p&gt;The exception is roles inside an organization the caller &lt;em&gt;is&lt;/em&gt; a member of.&lt;br&gt;
There, 403 is correct: the member knows the organization exists, they can see&lt;br&gt;
the button is disabled, and hiding the difference would only confuse them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: the queries cannot be called wrong
&lt;/h2&gt;

&lt;p&gt;sqlc generates a function per SQL statement. Write the statement with the&lt;br&gt;
tenant in the &lt;code&gt;WHERE&lt;/code&gt; clause and the generated signature demands it:&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;-- name: DeleteOrgInvitation :exec&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;org_invitations&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteOrgInvitation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteOrgInvitationParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;invID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrganizationID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orgCtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A developer who wants to bypass tenancy has to write a new SQL statement,&lt;br&gt;
which shows up in review as a file change in &lt;code&gt;internal/db/queries/&lt;/code&gt;. That is&lt;br&gt;
the property you want: the unsafe path is visible, not convenient.&lt;/p&gt;

&lt;p&gt;This is also why the kit uses sqlc rather than an ORM. With a query builder,&lt;br&gt;
&lt;code&gt;Invitation.find(id)&lt;/code&gt; compiles, runs, and returns another tenant's row. There&lt;br&gt;
is no equivalent of that mistake here, because there is no generic finder.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: roles are checked server-side
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleOwner&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orgsH&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The frontend also hides the delete button, and that check is explicitly&lt;br&gt;
cosmetic. Anyone can open the console and call the endpoint; the middleware is&lt;br&gt;
what answers.&lt;/p&gt;

&lt;p&gt;One rule that is easy to miss: the &lt;strong&gt;last owner&lt;/strong&gt; of an organization cannot be&lt;br&gt;
demoted, removed, or leave. Without it, organizations become unadministrable&lt;br&gt;
and support tickets become database surgery.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 6: the test that keeps it true
&lt;/h2&gt;

&lt;p&gt;Every one of the above can be undone by a well-meaning refactor. So the&lt;br&gt;
property gets a test at the level where it actually matters — through HTTP,&lt;br&gt;
against a real database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Create user A and organization A.
2. Create user B and organization B.
3. As B,   GET   /api/orgs/{orgA}                       → 404
4. As B,   GET   /api/orgs/{orgA}/members               → 404
5. As a member (not owner) of A,
           PATCH /api/orgs/{orgA}/members/{userID}      → 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 3 and 4 are the ones that matter. They are cheap to write once and they&lt;br&gt;
fail loudly the day someone adds a query without the tenant.&lt;/p&gt;

&lt;p&gt;In GoVueKit this exists twice: as integration tests run against both database&lt;br&gt;
engines in CI, and as a Playwright journey against the production binary.&lt;br&gt;
Write it at the same time as the resource, never afterwards — a test you must&lt;br&gt;
remember to add later is a test that gets added after the incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this design does not give you
&lt;/h2&gt;

&lt;p&gt;Be clear about the limits before you adopt it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No physical isolation.&lt;/strong&gt; A bug in a query can, in principle, return
another tenant's row. The mitigations are structural, not physical. If your
compliance requirement is literally separate databases, this is not the
model you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-tenant backup/restore.&lt;/strong&gt; Restoring one customer's data means
selective export, not a file copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Noisy neighbours share resources.&lt;/strong&gt; One tenant's heavy query can slow
others; you handle that with indexes, pagination and limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In exchange you get one database to operate, migrations that run once, and the&lt;br&gt;
ability to answer "how many organizations are on the paid plan?" with a single&lt;br&gt;
&lt;code&gt;GROUP BY&lt;/code&gt;. For the overwhelming majority of SaaS products, that trade is&lt;br&gt;
correct — and if you outgrow it, you outgrow it with revenue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-line summary for your own codebase
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put &lt;code&gt;organization_id&lt;/code&gt; on every business table, with a cascade and an index.&lt;/li&gt;
&lt;li&gt;Resolve membership in one middleware, from the URL, after authentication.&lt;/li&gt;
&lt;li&gt;Answer 404 to outsiders, 403 only inside an organization the caller belongs
to.&lt;/li&gt;
&lt;li&gt;Write queries that take the tenant, so the generated code refuses to be
called without it.&lt;/li&gt;
&lt;li&gt;Write the cross-tenant test once per resource, and run it in CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;See it in the &lt;a href="https://govuekit.dev/#code" rel="noopener noreferrer"&gt;code excerpts on the landing page&lt;/a&gt;, or read how the&lt;br&gt;
same discipline applies to &lt;a href="https://govuekit.dev/blog/idempotent-billing-webhooks" rel="noopener noreferrer"&gt;billing webhooks&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/row-level-multi-tenancy-go" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>database</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
