<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>seyshee.dev</title><subtitle>Notes on software, networks and small side projects. Written from Yerevan.</subtitle>
<link href="https://seyshee.dev/atom.xml" rel="self"/><link href="https://seyshee.dev/"/><id>https://seyshee.dev/</id>
<updated>2026-10-18T09:00:00Z</updated><author><name>seyshee</name></author>
<entry><title>Self-hosting a tiny static site with nginx</title><link href="https://seyshee.dev/notes/tiny-static-site-nginx/"/>
<id>https://seyshee.dev/notes/tiny-static-site-nginx/</id><updated>2026-10-18T09:00:00Z</updated><summary>Why a folder of HTML files and a good cache policy beat most frameworks for a personal page.</summary>
<content type="html">&lt;p&gt;This site used to be a single &lt;code&gt;index.html&lt;/code&gt;. It is now a few dozen HTML files produced by a Python script, and that&#x27;s about as complicated as I want it to get. Here is the whole setup, mostly so that I remember it next time.&lt;/p&gt;
&lt;h2 id=&quot;the-build&quot;&gt;The build&lt;/h2&gt;
&lt;p&gt;There is no framework. A ~300-line &lt;code&gt;build.py&lt;/code&gt; reads Markdown files with a tiny front matter block, renders them into one HTML template and writes everything into &lt;code&gt;dist/&lt;/code&gt;. It also packs project tarballs and makes thumbnails for the gallery. The only dependency is Pillow.&lt;/p&gt;
&lt;p&gt;I tried Hugo and liked it, but every time I came back after six months the theme had changed its config format. A script I wrote myself doesn&#x27;t do that.&lt;/p&gt;
&lt;h2 id=&quot;deploying&quot;&gt;Deploying&lt;/h2&gt;
&lt;p&gt;Deploy is &lt;code&gt;rsync&lt;/code&gt; to a VPS with a backup of the previous version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;ssh host &quot;rsync -a /var/www/site/ /root/site-backups/site-$(date +%F-%H%M)/&quot;
rsync -rlt --delete --chmod=D755,F644 dist/ host:/var/www/site/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rollback is the same command in the other direction. I keep the last ten backups; the whole site is a few dozen megabytes, mostly images.&lt;/p&gt;
&lt;h2 id=&quot;nginx&quot;&gt;nginx&lt;/h2&gt;
&lt;p&gt;The server block is short. The interesting part is cache policy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;location / {
    try_files $uri $uri/ =404;
}
location /static/ {
    expires 1y;
    add_header Cache-Control &quot;public, immutable&quot;;
}
location ~* \.(jpg|png|webp|tar\.gz)$ {
    expires 30d;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The stylesheet URL contains a hash of its content (&lt;code&gt;style.css?v=1a2b3c4d&lt;/code&gt;), so it can be cached forever: when the file changes, the URL changes. HTML is not cached at all, which is fine, it is tiny.&lt;/p&gt;
&lt;h2 id=&quot;things-i-d-tell-myself-a-year-ago&quot;&gt;Things I&#x27;d tell myself a year ago&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;code&gt;try_files $uri $uri/ =404&lt;/code&gt; plus directories with &lt;code&gt;index.html&lt;/code&gt; give you clean URLs for free. No rewrite rules.&lt;/li&gt;&lt;li&gt;Turn on &lt;code&gt;http2&lt;/code&gt; and TLS 1.3 and forget about it. There is nothing to tune for a site this size.&lt;/li&gt;&lt;li&gt;Add a proper &lt;code&gt;404.html&lt;/code&gt; and &lt;code&gt;error_page 404 /404.html;&lt;/code&gt;. The default nginx error page is ugly and tells everyone your exact nginx version.&lt;/li&gt;&lt;li&gt;&lt;code&gt;server_tokens off;&lt;/code&gt; while you&#x27;re at it.&lt;/li&gt;&lt;li&gt;A single-vCPU box with 2 GB of RAM serves static files faster than you&#x27;ll ever need. The bottleneck is the uplink, not nginx.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;That&#x27;s it. Boring, which is the point.&lt;/p&gt;</content></entry>
<entry><title>Let&#x27;s Encrypt renewals that never surprise you</title><link href="https://seyshee.dev/notes/lets-encrypt-renewals/"/>
<id>https://seyshee.dev/notes/lets-encrypt-renewals/</id><updated>2026-09-02T09:00:00Z</updated><summary>Deploy hooks, dry runs, and a monitoring check that actually fires.</summary>
<content type="html">&lt;p&gt;Certificates from Let&#x27;s Encrypt are valid for 90 days and certbot renews them at 30 days left. In theory, you never think about it. In practice, every few years renewal silently breaks and you find out from a browser warning.&lt;/p&gt;
&lt;p&gt;The three times it happened to me:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;A firewall change closed port 80, so the HTTP-01 challenge could not reach the webroot.&lt;/li&gt;&lt;li&gt;Renewal worked, but nothing reloaded the web server, so it kept serving the old certificate from memory.&lt;/li&gt;&lt;li&gt;A second service used the same certificate files and was never restarted.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;All three are fixable with a little discipline.&lt;/p&gt;
&lt;h2 id=&quot;use-the-webroot-and-test-it&quot;&gt;Use the webroot, and test it&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;certbot certonly --webroot -w /var/www/acme -d example.com -d www.example.com
certbot renew --dry-run&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--dry-run&lt;/code&gt; talks to the staging environment and goes through the full challenge. Run it after any change to the firewall or the web server config. It takes ten seconds.&lt;/p&gt;
&lt;h2 id=&quot;deploy-hooks&quot;&gt;Deploy hooks&lt;/h2&gt;
&lt;p&gt;Anything in &lt;code&gt;/etc/letsencrypt/renewal-hooks/deploy/&lt;/code&gt; runs after a &lt;em&gt;successful&lt;/em&gt; renewal. Mine is three lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;#!/bin/sh
systemctl reload nginx
systemctl restart my-other-thing&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make it executable. certbot ignores files without the &lt;code&gt;x&lt;/code&gt; bit, silently.&lt;/p&gt;
&lt;h2 id=&quot;monitor-from-the-outside&quot;&gt;Monitor from the outside&lt;/h2&gt;
&lt;p&gt;The real fix for &quot;I find out from a browser warning&quot; is a check that looks at what clients actually see, not at files on disk. I wrote &lt;a href=&quot;/projects/certpeek/&quot;&gt;certpeek&lt;/a&gt; for that: it connects to each host, reads the certificate that is actually served and exits non-zero if it expires in less than N days.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;$ certpeek --warn 21 example.com seyshee.dev
example.com     OK    67 days  (Let&#x27;s Encrypt R11)
seyshee.dev     OK    74 days  (Let&#x27;s Encrypt R10)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It runs from a systemd timer on a different machine and sends me a message if the exit code is not zero. That covers case 2 and 3 above, which no amount of looking at &lt;code&gt;/etc/letsencrypt/live/&lt;/code&gt; would have caught.&lt;/p&gt;</content></entry>
<entry><title>Reading TCP retransmits on a slow uplink</title><link href="https://seyshee.dev/notes/tcp-retransmits/"/>
<id>https://seyshee.dev/notes/tcp-retransmits/</id><updated>2026-07-21T09:00:00Z</updated><summary>A short walk through ss, tcpdump and what the numbers mean.</summary>
<content type="html">&lt;p&gt;My home connection is usually fine, but a couple of times a week uploads crawl. Before calling the ISP I wanted numbers. Linux already collects most of them, you just need to know where to look.&lt;/p&gt;
&lt;h2 id=&quot;ss-ti&quot;&gt;ss -ti&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ss -ti&lt;/code&gt; prints internal TCP state for every connection. The interesting fields:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cubic wscale:7,7 rto:236 rtt:35.1/2.4 mss:1448 cwnd:10 ssthresh:7
bytes_sent:1849204 bytes_retrans:93312 retrans:0/66 lost:0&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;&lt;li&gt;&lt;code&gt;rtt:35.1/2.4&lt;/code&gt; — smoothed round-trip time and its variance, in ms.&lt;/li&gt;&lt;li&gt;&lt;code&gt;cwnd&lt;/code&gt; — congestion window in segments. If it stays small on a long transfer, the sender keeps hitting loss.&lt;/li&gt;&lt;li&gt;&lt;code&gt;retrans:0/66&lt;/code&gt; — currently outstanding / total retransmitted segments.&lt;/li&gt;&lt;li&gt;&lt;code&gt;bytes_retrans / bytes_sent&lt;/code&gt; — the number I actually care about. Here it is ~5%, which is bad. On a healthy link it&#x27;s well under 1%.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Reading this by eye gets old quickly, so I wrote &lt;a href=&quot;/projects/retrans/&quot;&gt;retrans&lt;/a&gt;, which parses &lt;code&gt;ss -tin&lt;/code&gt; and prints one line per connection with the retransmit ratio, sorted worst first. It can also run in a loop and print deltas.&lt;/p&gt;
&lt;h2 id=&quot;tcpdump-when-you-need-proof&quot;&gt;tcpdump, when you need proof&lt;/h2&gt;
&lt;p&gt;If the ratio is high, a capture tells you whether loss is upstream or local:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;tcpdump -i eth0 -w upload.pcap &#x27;tcp port 443 and host 203.0.113.10&#x27;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open it in Wireshark, filter on &lt;code&gt;tcp.analysis.retransmission&lt;/code&gt;, and look at the timing. Loss that comes in bursts every few seconds usually means a queue somewhere is overflowing (bufferbloat). Random single losses look more like a bad line.&lt;/p&gt;
&lt;h2 id=&quot;what-it-was&quot;&gt;What it was&lt;/h2&gt;
&lt;p&gt;Bufferbloat on the router. Turning on SQM (&lt;code&gt;cake&lt;/code&gt; with the upload limit set to ~90% of the real speed) brought the retransmit ratio down to 0.2% and made video calls stop stuttering while something is uploading. No call to the ISP needed.&lt;/p&gt;</content></entry>
<entry><title>Postgres advisory locks as a poor man&#x27;s job scheduler</title><link href="https://seyshee.dev/notes/postgres-advisory-locks/"/>
<id>https://seyshee.dev/notes/postgres-advisory-locks/</id><updated>2026-06-03T09:00:00Z</updated><summary>Running a cron job on three app servers without running it three times.</summary>
<content type="html">&lt;p&gt;We had a cleanup job that ran every ten minutes. Then the app went from one server to three, and so did the job. It was idempotent, mostly, but it was also slow and it held row locks, so three copies at once were noticeably worse than one.&lt;/p&gt;
&lt;p&gt;The usual answers are &quot;use a proper scheduler&quot; or &quot;pick a leader&quot;. Both are fine. But if you already have Postgres, there is a cheaper one: advisory locks.&lt;/p&gt;
&lt;h2 id=&quot;the-idea&quot;&gt;The idea&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT pg_try_advisory_lock(42);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;returns &lt;code&gt;true&lt;/code&gt; for exactly one session at a time; everyone else gets &lt;code&gt;false&lt;/code&gt; immediately. The lock is released when the session ends or when you call &lt;code&gt;pg_advisory_unlock(42)&lt;/code&gt;. The number is yours to choose — I hash the job name into a &lt;code&gt;bigint&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;in-code&quot;&gt;In code&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func runExclusive(ctx context.Context, db *sql.DB, name string, fn func(context.Context) error) error {
	conn, err := db.Conn(ctx) // the lock belongs to a session, so pin one connection
	if err != nil {
		return err
	}
	defer conn.Close()

	key := int64(fnv64(name))
	var ok bool
	if err := conn.QueryRowContext(ctx, &quot;SELECT pg_try_advisory_lock($1)&quot;, key).Scan(&amp;amp;ok); err != nil {
		return err
	}
	if !ok {
		return nil // someone else is doing it
	}
	defer conn.ExecContext(context.Background(), &quot;SELECT pg_advisory_unlock($1)&quot;, key)
	return fn(ctx)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The one thing that bites people: the lock is per &lt;em&gt;session&lt;/em&gt;, so you must run lock, work and unlock on the same connection. With a connection pool, take a dedicated &lt;code&gt;*sql.Conn&lt;/code&gt; as above.&lt;/p&gt;
&lt;h2 id=&quot;caveats&quot;&gt;Caveats&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;If you use PgBouncer in transaction mode, session-level advisory locks don&#x27;t work. Use &lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt; inside a transaction instead.&lt;/li&gt;&lt;li&gt;This gives you mutual exclusion, not scheduling. Every server still wakes up every ten minutes; only one of them does anything.&lt;/li&gt;&lt;li&gt;If the process dies, the connection drops and the lock is released. That&#x27;s a feature.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Two years later this is still running. Boring, again.&lt;/p&gt;</content></entry>
<entry><title>Sunday plotting</title><link href="https://seyshee.dev/notes/sunday-plotting/"/>
<id>https://seyshee.dev/notes/sunday-plotting/</id><updated>2026-04-12T09:00:00Z</updated><summary>Generative sketches in pure Python, and why I stopped using numpy for them.</summary>
<content type="html">&lt;p&gt;Some weekends I write small programs whose only output is a picture. The results live in the &lt;a href=&quot;/gallery/&quot;&gt;gallery&lt;/a&gt;. A few people asked how they are made, so here&#x27;s the short version.&lt;/p&gt;
&lt;h2 id=&quot;the-toolbox&quot;&gt;The toolbox&lt;/h2&gt;
&lt;p&gt;Everything is Python and Pillow. No numpy, no Processing, no GPU. Rendering one 3000×2000 image takes between five seconds and a minute, which is a perfectly good speed for something you look at for a week.&lt;/p&gt;
&lt;p&gt;Pillow&#x27;s &lt;code&gt;ImageDraw&lt;/code&gt; does not antialias lines, so I draw at twice the size and downscale with &lt;code&gt;LANCZOS&lt;/code&gt;. That&#x27;s the single biggest quality improvement per line of code.&lt;/p&gt;
&lt;h2 id=&quot;noise&quot;&gt;Noise&lt;/h2&gt;
&lt;p&gt;Most of the sketches start from 2D gradient noise. The whole implementation is about 25 lines: a shuffled permutation table, a table of random unit vectors, and a smoothstep between the four corners of a cell. Layer a few octaves (fractal Brownian motion) and you get something that looks like terrain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def fbm(self, x, y, octaves=4):
    s, amp, f = 0.0, 0.5, 1.0
    for _ in range(octaves):
        s += amp * self(x * f, y * f)
        amp *= 0.5; f *= 2.0
    return s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From there:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Flow field&lt;/strong&gt; — treat noise as an angle, drop thousands of particles, let each walk along the angle for a while and draw its path.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Contours&lt;/strong&gt; — sample noise on a grid and run marching squares at a few dozen levels. Every fifth line is brighter, like on a real map.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Two peaks&lt;/strong&gt; — the same noise as a horizon line, plus two gaussian bumps. If you&#x27;ve been to Yerevan you know which mountain it is.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;I&#x27;ve cleaned up the noise and drawing helpers into &lt;a href=&quot;/projects/noisepad/&quot;&gt;noisepad&lt;/a&gt; in case anyone wants to play.&lt;/p&gt;
&lt;h2 id=&quot;why-not-numpy&quot;&gt;Why not numpy&lt;/h2&gt;
&lt;p&gt;I started with numpy and it was faster. But half the fun is reading the code a year later and still understanding it, and loops over pixels are easier to read than broadcasting tricks. Speed has never been the bottleneck — deciding which of the twenty variations to keep is.&lt;/p&gt;</content></entry>
<entry><title>I replaced cron with systemd timers and I&#x27;m not going back</title><link href="https://seyshee.dev/notes/systemd-timers/"/>
<id>https://seyshee.dev/notes/systemd-timers/</id><updated>2026-02-08T09:00:00Z</updated><summary>Logs, missed runs, randomized delays and dependencies, for the price of two small files.</summary>
<content type="html">&lt;p&gt;For years my servers had a crontab with ten lines and no idea whether any of them worked. Moving them to systemd timers took an afternoon. Here&#x27;s what I got for it.&lt;/p&gt;
&lt;h2 id=&quot;two-files-instead-of-one-line&quot;&gt;Two files instead of one line&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# /etc/systemd/system/backup.service
[Unit]
Description=Nightly database backup
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly

[Timer]
OnCalendar=*-*-* 03:15
RandomizedDelaySec=20m
Persistent=true

[Install]
WantedBy=timers.target&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then &lt;code&gt;systemctl enable --now backup.timer&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-you-get&quot;&gt;What you get&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Logs.&lt;/strong&gt; &lt;code&gt;journalctl -u backup&lt;/code&gt; shows every run, its output and exit code. With cron I had &lt;code&gt;&amp;gt;&amp;gt; /var/log/backup.log 2&amp;gt;&amp;amp;1&lt;/code&gt; and hoped.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Missed runs.&lt;/strong&gt; &lt;code&gt;Persistent=true&lt;/code&gt; runs the job at boot if the machine was off at 03:15.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;No thundering herd.&lt;/strong&gt; &lt;code&gt;RandomizedDelaySec&lt;/code&gt; spreads jobs across servers that share a config.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Status at a glance.&lt;/strong&gt; &lt;code&gt;systemctl list-timers&lt;/code&gt; shows the last and next run of everything.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Failure hooks.&lt;/strong&gt; &lt;code&gt;OnFailure=notify@%n.service&lt;/code&gt; in the &lt;code&gt;[Unit]&lt;/code&gt; section, and a tiny template unit that sends a message. This alone was worth the move.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;what-you-lose&quot;&gt;What you lose&lt;/h2&gt;
&lt;p&gt;The one-line convenience. For quick experiments I still use &lt;code&gt;systemd-run --on-calendar=... &lt;/code&gt; which gives a transient timer without writing files.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;OnCalendar&lt;/code&gt; syntax takes a minute to learn; &lt;code&gt;systemd-analyze calendar &quot;Mon *-*-* 09:00&quot;&lt;/code&gt; prints the next trigger times, which helps.&lt;/p&gt;</content></entry>
<entry><title>A year of working from Yerevan</title><link href="https://seyshee.dev/notes/a-year-in-yerevan/"/>
<id>https://seyshee.dev/notes/a-year-in-yerevan/</id><updated>2025-12-14T09:00:00Z</updated><summary>Cafes, power cuts that never happened, and a city that is very good at being lived in.</summary>
<content type="html">&lt;p&gt;It&#x27;s been a bit more than a year since I moved to Yerevan. I promised a couple of friends I&#x27;d write down what working remotely from here is actually like, so here it is, in no particular order.&lt;/p&gt;
&lt;h2 id=&quot;internet&quot;&gt;Internet&lt;/h2&gt;
&lt;p&gt;Better than I expected. Fiber is common in the center; I pay about 8,000 dram a month for a symmetric connection that&#x27;s been down twice in a year, both times for under an hour. Mobile data is cheap and fast enough to work from a phone hotspot in a pinch.&lt;/p&gt;
&lt;h2 id=&quot;places-to-work&quot;&gt;Places to work&lt;/h2&gt;
&lt;p&gt;The city is full of cafes where nobody minds a laptop for a few hours as long as you keep ordering. My rule is one drink per ninety minutes. Coworking spaces exist and are fine, but I mostly work from home and go out for the afternoon.&lt;/p&gt;
&lt;h2 id=&quot;things-i-didn-t-expect&quot;&gt;Things I didn&#x27;t expect&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;How walkable the center is. I haven&#x27;t needed a car once.&lt;/li&gt;&lt;li&gt;The pink tufa stone everywhere — the &lt;a href=&quot;/gallery/&quot;&gt;tufa grid&lt;/a&gt; sketch is a small homage.&lt;/li&gt;&lt;li&gt;Ararat on clear mornings. You stop noticing it after a while and then one day it&#x27;s just &lt;em&gt;there&lt;/em&gt;, huge, and you stop on the street like a tourist.&lt;/li&gt;&lt;li&gt;Summers are hot. Really hot. August is a good month to be somewhere else.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;language&quot;&gt;Language&lt;/h2&gt;
&lt;p&gt;My Armenian is still at the &quot;thank you, the bill please, how much&quot; level. Russian and English cover almost everything. People are patient with the rest.&lt;/p&gt;
&lt;p&gt;I&#x27;m staying.&lt;/p&gt;</content></entry>
<entry><title>In defense of boring backends</title><link href="https://seyshee.dev/notes/boring-backends/"/>
<id>https://seyshee.dev/notes/boring-backends/</id><updated>2025-10-05T09:00:00Z</updated><summary>One process, one database, one deploy script. You can go surprisingly far.</summary>
<content type="html">&lt;p&gt;Every few months I see a small project with a message queue, three microservices, a cache cluster and a service mesh, serving a hundred users. I understand the appeal — it&#x27;s fun to build — but it is rarely what the project needs.&lt;/p&gt;
&lt;p&gt;Here&#x27;s the stack I default to, and I&#x27;ve yet to regret it:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;One process.&lt;/strong&gt; A Go or Python app that serves HTTP and runs background jobs in goroutines or a worker thread.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;One database.&lt;/strong&gt; Postgres. It is also the queue (&lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;), the lock service (advisory locks), the full-text search and the cache for anything that isn&#x27;t hot.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;One reverse server.&lt;/strong&gt; nginx in front, for TLS and static files.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;One deploy script.&lt;/strong&gt; Build, copy, restart, check. Keep the previous build to roll back.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;when-to-add-something&quot;&gt;When to add something&lt;/h2&gt;
&lt;p&gt;Add a component when you can name the problem it solves &lt;em&gt;and&lt;/em&gt; you&#x27;ve measured that problem. &quot;Postgres is too slow for this query&quot; is a reason to add an index first, a read replica second, and a cache third. &quot;We might need to scale&quot; is not a reason.&lt;/p&gt;
&lt;h2 id=&quot;what-boring-buys-you&quot;&gt;What boring buys you&lt;/h2&gt;
&lt;p&gt;Fewer things to monitor. Fewer things to upgrade. Fewer ways to fail at 3 a.m. And — the part people underestimate — a system that a new person can understand in a day.&lt;/p&gt;
&lt;p&gt;The most reliable systems I&#x27;ve worked on were the ones nobody talked about.&lt;/p&gt;</content></entry>
<entry><title>Backing up SQLite without stopping the app</title><link href="https://seyshee.dev/notes/sqlite-online-backup/"/>
<id>https://seyshee.dev/notes/sqlite-online-backup/</id><updated>2025-08-20T09:00:00Z</updated><summary>cp is not a backup. VACUUM INTO and the .backup command are.</summary>
<content type="html">&lt;p&gt;Copying a SQLite file with &lt;code&gt;cp&lt;/code&gt; while the app is writing to it gives you a file that &lt;em&gt;usually&lt;/em&gt; opens. Usually is not a word I want near backups.&lt;/p&gt;
&lt;h2 id=&quot;option-1-vacuum-into&quot;&gt;Option 1: VACUUM INTO&lt;/h2&gt;
&lt;p&gt;Since SQLite 3.27:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;VACUUM INTO &#x27;/backups/app-2025-08-20.db&#x27;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It writes a consistent, compacted copy of the database to a new file. It runs inside a read transaction, so writers in WAL mode keep working.&lt;/p&gt;
&lt;h2 id=&quot;option-2-the-online-backup-api&quot;&gt;Option 2: the online backup API&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;sqlite3 /srv/app/app.db &quot;.backup &#x27;/backups/app.db&#x27;&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This uses the online backup API, which copies pages incrementally and restarts if the source changes underneath. For big, busy databases &lt;code&gt;VACUUM INTO&lt;/code&gt; tends to be more predictable; for small ones either is fine.&lt;/p&gt;
&lt;h2 id=&quot;then-check-it&quot;&gt;Then check it&lt;/h2&gt;
&lt;p&gt;A backup you haven&#x27;t restored is a hope. My nightly job does:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;sqlite3 &quot;$OUT&quot; &quot;PRAGMA integrity_check;&quot; | grep -qx ok || exit 1
zstd -q --rm &quot;$OUT&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and once a month I restore the latest one on my laptop and click around.&lt;/p&gt;
&lt;h2 id=&quot;wal-mode&quot;&gt;WAL mode&lt;/h2&gt;
&lt;p&gt;If you haven&#x27;t already, &lt;code&gt;PRAGMA journal_mode=WAL;&lt;/code&gt; is the single best change for an app with concurrent readers. It also makes both backup methods above friendlier.&lt;/p&gt;</content></entry>
</feed>
