This gives me "checking the blinking lights on my Linksys router back when they still had the blue front face" nostalgia, thank you
I'm trying my best to make systems understandable. Years ago I read about how macOS (and software writ large) got rid of status bars, syncing/activity windows, and it stuck with me. So for Little CRM, I’m adding status lights to show if JS appears to be working, and if the Web Sockets connection was successful.
The lowest latency combination between Hetzner ARM and Crunchy Bridge
tl;dr: The best combination of Hetzner's ARM servers and Crunchy Bridge's regions are:
- Hetzner:
nbg1-dc3
- Crunchy Bridge: AWS
eu-central-1
- Benchmarks at the end of the post
I’ve been working on getting the first Practical Computer app out of beta (a dispatcher app for service companies). It, and the rest of our smaller apps, have the following constraints:
- Extremely shoestring budget. $30/month operating budget. Not much to work with!
- Since it’s production data, there’s responsibility for the data to be redundant, safe, and secure.
Given those, I ended up choosing Hetzner’s ARM servers, given the sheer power + cost combination (even with them being in the EU; and this is a US-based project).
I’ve had great experiences with the Crunchy Bridge team, and 18+ years of experience have taught me that I really want someone else to make sure the database is secure for me. So even though it’s 10% of my operating budget here, it’s worth it (because the database is the most crucial part of the app!).
The problem is that the app server & database are on two separate networks; I don’t get the benefit of an internal network, so I had to benchmark what combination of regions would work best.
The previous iteration was completely misconfigured: Hetzner’s Helsinki region for the app, AWS eu-north–1
for Crunchy Bridge. This turned out to be awful for performance. After some benchmarking and experimenting, I found that the following combination gets pretty dang close to good performance given the constraints:
- Hetzner:
nbg1-dc3
- Crunchy Bridge: AWS
eu-central–1
(I’ve got benchmarks at the end of the blog post, don’t worry!)
Lessons learned
I wanted to write this not only to help other folks find the best combination easily; but to talk about the mistakes I made and lessons I learned.
- Mistake: I should have benchmarked different options & done more research. My first Crunchy Bridge cluster was actually in
eu-central–1
, with the Hetzner server in Helsinki; and if I’d benchmarked & explored my options I would have avoided an unnecessary database migration. - Win: When spooling up the Hetzner server, I did my best to use Ansible to provision the server, and use Dokku for a PaaS-like experience. This made it so that spooling up a new server in
nbg1-dc3
was significantly easier. This is why I advocate for “automate as you go”, especially for infrastructure. - Win: choosing the right vendors. Hetzner allows me to quickly & cheaply spool up a small project, and having Crunchy Bridge’s support & their tooling made spooling up the replicas & promoting them dead-simple.
Alternative solves
To be clear, I had a few other options available to me, some of which I could still choose, and have implemented:
- Application-level caching: This is done, and basically a given for any production app. I ended up using Redis here.
- Making a read replica on the Hetzner instance; use that for reads (a heroically misguided, but technically feasible idea!)
- Move from Hetzner to AWS. This would only make sense for an app that’s actually generating the profit to justify the switch. It’s hard to bear Hetzner’s price & power combo here.
- Move the primary DB back to Hetzner (not ideal. Again, 18+ years of experience have taught me that I don’t want to manage a bunch of databases)
Benchmarks, as promised
# Benchmark script # x.report("crunchy-eu-central") { ApplicationRecord.connected_to(role: :crunchy-eu-central){ Organization.first.onsites }} # x.report("crunchy-eu-north-1") { ApplicationRecord.connected_to(role: :crunchy-eu-north-1){ Organization.first.onsites } }hetzner-helsinki:~$ dokku run dispatcher rails@aaaaaa:/rails$ ruby script/benchmarks/database_performance.rb … ruby 3.3.1 (2024-04-23 revision c56cd86388) +YJIT [aarch64-linux] Warming up ————————————– crunchy-eu-central 1.000 i/100ms crunchy-eu-north-1 1.000 i/100ms Calculating ————————————- crunchy-eu-central 9.788 (±10.2%) i/s - 49.000 in 5.031111s crunchy-eu-north-1 14.124 (± 7.1%) i/s - 71.000 in 5.045164s
Comparison: crunchy-eu-north-1: 14.1 i/s crunchy-eu-central: 9.8 i/s - 1.44x slower
====================================================================================================================
hetzner-nuremberg:~$ dokku run dispatcher rails@5e9d1df43591:/rails$ ruby script/benchmarks/database_performance.rb … ruby 3.3.1 (2024-04-23 revision c56cd86388) +YJIT [aarch64-linux] Warming up ————————————– crunchy-eu-central 1.000 i/100ms crunchy-eu-north-1 7.000 i/100ms Calculating ————————————- crunchy-eu-central 20.452 (± 0.0%) i/s - 103.000 in 5.037650s crunchy-eu-north-1 96.340 (± 2.1%) i/s - 483.000 in 5.016063s
Comparison: crunchy-eu-north-1: 96.3 i/s crunchy-eu-central: 20.5 i/s - 4.71x slower
====================================================================================================================
/// Spot-check of query latency
hetzner-helsinki:~$ dokku run dispatcher rails@aaaaaa:/rails$ bin/rails c Loading production environment (Rails 7.1.3.2) irb(main):001> 10.times { p Benchmark.ms{ ApplicationRecord.connected_to(role: :crunchy-eu-central){ Organization.first.onsites } } } 1637.3784099705517 103.8463581353426 106.55084392055869 105.82964192144573 102.70471591502428 102.40731597878039 107.70620591938496 112.95577604323626 111.48629407398403 105.25572090409696 => 10 irb(main):002> 10.times { p Benchmark.ms{ ApplicationRecord.connected_to(role: :crunchy-eu-north-1){ Organization.first.onsites } } } 1040.9399520140141 72.33657804317772 73.68614105507731 77.82750786282122 75.32874401658773 73.2035799883306 77.43134815245867 72.01353716664016 71.68629695661366 74.11726214922965 => 10
====================================================================================================================
hetzner-nuremberg:~$ dokku run dispatcher rails@aaaaaa:/rails$ bin/rails c Loading production environment (Rails 7.1.3.2) irb(main):001> 10.times { p Benchmark.ms{ ApplicationRecord.connected_to(role: :crunchy-eu-central){ Organization.first.onsites } } } 846.7416610001237 54.84945299758692 56.269697000971064 55.53825500101084 54.511651000211714 54.32877099883626 57.52018099883571 59.374027001467766 63.36915899737505 55.06629299998167 => 10 irb(main):002> 10.times { p Benchmark.ms{ ApplicationRecord.connected_to(role: :crunchy-eu-north-1){ Organization.first.onsites } } } 168.30048900010297 11.614236002060352 12.266477999219205 15.882210002018837 15.326369000831619 11.819797000498511 14.219884997146437 10.650393000105396 11.742956001398852 13.63104199845111 => 10
"Do it right, or do it twice" Code Quality Edition
Inspired by Lucian’s post, I finally setup code quality for the first Practical Computer app.
This whole process was definitely borderline “do it right, or do it twice.” I wish I’d solved this a bit sooner. I knew it was necessary, but had kept pushing it back because the app isn’t even close to being live yet. But this line from Lucian’s post changed my opinion:
Side projects developed while having a full-time job have a unique characteristic worth noting. The time dedicated to working on the side project is not continuous. For instance, you may work on it for 1-2 hours on Saturday, and the next opportunity to work on it may only arise a week later.
It is then essential to make the code quality built-in and use as much automation as possible.
That’s a very strong argument, and one I hadn’t heard yet. Of course, since I delayed, it caused the past 3 work sessions to be solely about fixing up the repo. But the upside is now I have all the code ready for the next project. And speaking of that, here’s a gist of my customized set of Rubocop & CircleCI configuration. I hope it helps!
I made a few technical differences than Lucian:
- I trimmed down the Rubocops used. This gives me a balance of expressiveness & the benefits of a linter
- I chose CircleCI because it’s what I’ve been using for years and it’s Fine™️. Plus it has the distinct advantage of SSH access to debug jobs, and job reporters
- I’m using Bun, so relying on Dependabot for my JS dependencies
- I’m using Code Climate for maintainability monitoring
Building in public 1
👋 I’m stuck waiting on laundry to finish, so carving out some time for an announcement!
I’ve been wanting to build something in public for a while, and there’s no time like the present.
I’ve had an idea kicking around in my head for a privacy-first CRM that’s aimed at very small companies/operations. It’s directly informed by my own experiences working in Noko, and publicizing my music.
The audience is very focused on small operations. Stuff like my music, smaller SaaSes/service businesses. Where you want *some* place to keep track of leads & seeing who your most valuable customers are, but you specifically don’t want to track a ton of data.
And there are ancillary/modern assumptions, like tracking different product purchases by default, tracking influencers/promo efforts, and some way of ranking how effective you find particular marketing channels.
Some implementation notes:
- It’ll also be a real-world example of a passkeys first application; because right now there are way too few of those
- It’s a chance to really use Web Push!
- I know it’ll be a vanilla/minimal JS app; to prove again that you don’t need a ton to make a great, useful app.
- I think I’ll also be working on a vanilla JS/HTML variant of Standerd, made by my friends over at Nicer Studios. Have to try it out before I make that call