<?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: DockSky</title>
    <description>The latest articles on DEV Community by DockSky (@docksky).</description>
    <link>https://dev.to/docksky</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%2F3966883%2Ff2caf632-e8bc-46df-8ef1-b044fe808177.png</url>
      <title>DEV Community: DockSky</title>
      <link>https://dev.to/docksky</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/docksky"/>
    <language>en</language>
    <item>
      <title>How I Set Up HAProxy + ProxySQL on a Single OVH VPS for My Solo SaaS</title>
      <dc:creator>DockSky</dc:creator>
      <pubDate>Tue, 23 Jun 2026 05:31:05 +0000</pubDate>
      <link>https://dev.to/docksky/how-i-set-up-haproxy-proxysql-on-a-single-ovh-vps-for-my-solo-saas-5ec7</link>
      <guid>https://dev.to/docksky/how-i-set-up-haproxy-proxysql-on-a-single-ovh-vps-for-my-solo-saas-5ec7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I needed to expose MySQL on the internet without getting scanned into oblivion within 48 hours. I stacked HAProxy in front of ProxySQL in front of MySQL. It works. I also spent six hours fighting a proxy that reads its config file once in its life, then pretends it never met you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem (or: why I didn't just buy an $80/month RDS)
&lt;/h2&gt;

&lt;p&gt;I'm building &lt;strong&gt;DockSky&lt;/strong&gt;, an indie SaaS, solo, on an &lt;strong&gt;8 GB OVH VPS&lt;/strong&gt; for about €14/month. No ops team. No "managed database" budget. Just me, Docker, and optimism.&lt;/p&gt;

&lt;p&gt;Except DockSky isn't just a REST API with Postgres behind it. The product is &lt;strong&gt;managed multi-tenant MySQL&lt;/strong&gt;: each customer gets their own MySQL user, their own credentials, real isolation. Not a &lt;code&gt;tenant_id&lt;/code&gt; column and a prayer.&lt;/p&gt;

&lt;p&gt;For that to work, I need &lt;strong&gt;external MySQL access&lt;/strong&gt; on port &lt;strong&gt;6033&lt;/strong&gt;, without leaving MySQL wide open on the internet like a vending machine at 3 a.m.&lt;/p&gt;

&lt;p&gt;Before ProxySQL, I already had &lt;strong&gt;HAProxy&lt;/strong&gt; forwarding traffic to MySQL. It worked. In the sense that nobody had tried &lt;code&gt;SELECT * FROM users WHERE 1=1&lt;/code&gt; on me yet.&lt;/p&gt;

&lt;p&gt;What I was missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; at the network layer (a bot opening 500 connections is trivial)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection pooling&lt;/strong&gt; (my VPS has 8 GB of RAM, not a datacenter)&lt;/li&gt;
&lt;li&gt;A layer to &lt;strong&gt;route multi-tenant users&lt;/strong&gt; without hand-editing MySQL every time someone signs up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: a network gateway &lt;strong&gt;and&lt;/strong&gt; a SQL gateway. Two tools, two jobs. Like a bouncer at the club door and another at the VIP bar. Except nobody's paying cover yet. Still in beta.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture (Post-it edition)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet (port 6033, the only MySQL port open in UFW)
    ↓
HAProxy          → "Be nice, but not too many connections"
    ↓
ProxySQL         → "Does your user exist? Is this query acceptable?"
    ↓
MySQL 8.x        → localhost only, like a well-behaved introvert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the side, &lt;strong&gt;Traefik&lt;/strong&gt; handles HTTPS for admin interfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;haproxy-stats.docksky.fr&lt;/code&gt; to see if everything's green&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;proxysql-admin.docksky.fr&lt;/code&gt; to stare at tables I only half understand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I created the subdomains via the &lt;strong&gt;OVH DNS API&lt;/strong&gt;. Because clicking 40 times in the OVH panel is the kind of task that makes me want to quit software and open a food truck.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I actually deployed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Lock down MySQL (finally)
&lt;/h3&gt;

&lt;p&gt;MySQL only listens locally:&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;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3306:3306"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want my data? Get in line. Like the post office, but with rate limiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. HAProxy: friendly but firm bouncer
&lt;/h3&gt;

&lt;p&gt;Frontend on &lt;code&gt;*:6033&lt;/code&gt;, backend to &lt;code&gt;proxysql:6032&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stick-table type ip size 100k expire 30s store conn_cur,conn_rate(3s)
tcp-request connection track-sc0 src
tcp-request connection track-sc1 src
tcp-request connection reject if { sc0_conn_cur ge 5 }
tcp-request connection reject if { sc1_conn_rate ge 10 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Human translation: &lt;strong&gt;5 simultaneous connections max per IP&lt;/strong&gt;, &lt;strong&gt;10 new connections max every 3 seconds&lt;/strong&gt;. Enough for an honest client. Not enough for a script kiddie with a &lt;code&gt;for&lt;/code&gt; loop and too much free time.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. ProxySQL: the six-hour side quest
&lt;/h3&gt;

&lt;p&gt;Docker service with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;template&lt;/strong&gt; &lt;code&gt;proxysql.cnf.template&lt;/code&gt; (no secrets in git, I learned that lesson earlier)&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;custom entrypoint&lt;/strong&gt; that generates config at startup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tmpfs&lt;/strong&gt; on &lt;code&gt;/var/lib/proxysql&lt;/code&gt; (more on that, it's the plot twist)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;beta user sync&lt;/strong&gt; from MySQL into ProxySQL at boot (otherwise every new DockSky customer means a manual intervention, and I don't have a support team)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entrypoint waits for ProxySQL to be ready, then syncs users. Because ProxySQL takes &lt;strong&gt;25 to 30 seconds&lt;/strong&gt; to wake up. Like me, but without coffee.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Version everything before the VPS explodes
&lt;/h3&gt;

&lt;p&gt;I spun up a &lt;strong&gt;&lt;code&gt;docksky-infra&lt;/code&gt;&lt;/strong&gt; repo with a full disaster recovery procedure. Because one day the VPS will die (Murphy's Law, Docker edition) and I don't want to rebuild this from memory on a Sunday night.&lt;/p&gt;




&lt;h2&gt;
  
  
  What broke (the part Dev.to loves)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Issue #1: Docker Compose 1.29.2
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;KeyError: 'ContainerConfig'&lt;/code&gt;. Couldn't recreate containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My reaction:&lt;/strong&gt; "It's not me, it's Docker." (Spoiler: it was Docker.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; upgrade to &lt;strong&gt;Docker Compose v2&lt;/strong&gt;. &lt;code&gt;docker compose&lt;/code&gt;, no hyphen. Like switching from a bike to a car, except the car costs fewer nerves.&lt;/p&gt;




&lt;h3&gt;
  
  
  Issue #2: ProxySQL and the ghost config file
&lt;/h3&gt;

&lt;p&gt;The trap that ate &lt;strong&gt;six hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;First boot: everything works. I think I'm a genius.&lt;br&gt;&lt;br&gt;
Container restart: &lt;code&gt;Access denied&lt;/code&gt;. I think I'm an idiot.&lt;/p&gt;

&lt;p&gt;Official docs, one killer sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"After first startup the DB file is used instead of the config file"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;ProxySQL creates an internal SQLite database on first boot. Then it &lt;strong&gt;ignores your &lt;code&gt;.cnf&lt;/code&gt;&lt;/strong&gt; like an ex ignoring your texts.&lt;/p&gt;

&lt;p&gt;I tried:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--initial&lt;/code&gt; → nope&lt;/li&gt;
&lt;li&gt;deleting the volume → the DB comes back, poltergeist style&lt;/li&gt;
&lt;li&gt;native env vars → ProxySQL doesn't care&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What actually works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;tmpfs&lt;/strong&gt; on &lt;code&gt;/var/lib/proxysql&lt;/code&gt;. SQLite dies on every restart, ProxySQL is &lt;em&gt;forced&lt;/em&gt; to reread the template&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;custom entrypoint&lt;/strong&gt; generates &lt;code&gt;/etc/proxysql.cnf&lt;/code&gt; with real passwords (&lt;code&gt;sed&lt;/code&gt; first, then &lt;code&gt;perl&lt;/code&gt; when &lt;code&gt;sed&lt;/code&gt; started giving me side-eye)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wait 30 seconds&lt;/strong&gt; before testing. Otherwise you think it's broken when it's just slow&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Trade-off:&lt;/strong&gt; any live change in ProxySQL (&lt;code&gt;INSERT INTO mysql_users&lt;/code&gt;, etc.) vanishes on restart. Source of truth is the template. Not the moody SQLite database.&lt;/p&gt;


&lt;h3&gt;
  
  
  Issue #3: HAProxy declares ProxySQL dead
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Logs:&lt;/strong&gt; &lt;code&gt;Access denied for user 'monitor'&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I'd set &lt;code&gt;option mysql-check user monitor&lt;/code&gt;. Except in ProxySQL, &lt;code&gt;monitor&lt;/code&gt; is for watching MySQL backends, not for saying "hey, you alive?" to the proxy itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;option tcp-check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port open? It's UP. Stoic philosophy for healthchecks.&lt;/p&gt;




&lt;h3&gt;
  
  
  Issue #4: Two stick-tables, one angry HAProxy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; crash loop, &lt;code&gt;stick-table name 'mysql-in' conflicts&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I'd declared two separate tables. HAProxy doesn't share well. One table, two counters. Like a Paris studio, but for IPs.&lt;/p&gt;




&lt;h3&gt;
  
  
  Issue #5: &lt;code&gt;envsubst&lt;/code&gt; doesn't exist in Alpine
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Logs:&lt;/strong&gt; &lt;code&gt;envsubst: command not found&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The ProxySQL image is minimal. I'd copied a 2019 Stack Overflow tutorial. Classic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;sed&lt;/code&gt;, then &lt;code&gt;perl&lt;/code&gt;. The current wrapper uses &lt;code&gt;perl&lt;/code&gt;. At some point you stop fighting &lt;code&gt;sed&lt;/code&gt; and accept defeat with dignity.&lt;/p&gt;




&lt;h3&gt;
  
  
  Issue #6: The silent restart (a few weeks later)
&lt;/h3&gt;

&lt;p&gt;ProxySQL refused connections after a restart. No noise. No email. No "sorry boss."&lt;/p&gt;

&lt;p&gt;I caught it in metrics within &lt;strong&gt;5 minutes&lt;/strong&gt;. Fixed in &lt;strong&gt;30&lt;/strong&gt;. That's exactly why I have healthchecks and a dashboard. Not to look good on a "our stack" slide.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's running today
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MySQL 8.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;✅ localhost only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ProxySQL 2.7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pooling + multi-tenant users&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HAProxy 2.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Network rate limiting&lt;/td&gt;
&lt;td&gt;✅ healthy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Traefik&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTTPS admin + rest of DockSky&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;UFW:&lt;/strong&gt; only &lt;strong&gt;6033&lt;/strong&gt; is open for MySQL. Everything else goes through Traefik or stays on localhost. My security policy is "paranoid, but not paranoid enough to shut everything down and work from a Raspberry Pi in a closet."&lt;/p&gt;




&lt;h2&gt;
  
  
  Is it worth it for a solo SaaS?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Honestly:&lt;/strong&gt; it depends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Yes if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you want multi-tenant MySQL without paying €80/month for managed DB&lt;/li&gt;
&lt;li&gt;you'll read the docs when things break (and they will)&lt;/li&gt;
&lt;li&gt;you document everything, because &lt;em&gt;future you&lt;/em&gt; remembers nothing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;No if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you want to sleep without thinking about ProxySQL&lt;/li&gt;
&lt;li&gt;you have 50 customers and no monitoring&lt;/li&gt;
&lt;li&gt;you hate proxies with a SQLite personality disorder&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For DockSky today, it's the right trade-off. A €14 VPS, a stack I understand layer by layer, and an infra repo I can rebuild from scratch if OVH decides on a Tuesday that my server has lived its best life.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade your tooling before adding a critical service.&lt;/strong&gt; Docker Compose v2 saved me before ProxySQL even showed up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the official docs.&lt;/strong&gt; The SQLite sentence was there. I just took 4 hours to find it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple healthchecks beat clever ones.&lt;/strong&gt; TCP check is ugly but works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait 30 seconds.&lt;/strong&gt; ProxySQL isn't lazy. It's… deliberate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version everything.&lt;/strong&gt; Shout-out to my dated &lt;code&gt;docker-compose.yml&lt;/code&gt; backup from November 17, 2025.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor.&lt;/strong&gt; A proxy that dies quietly is a proxy you discover when a customer DMs you.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The punchline
&lt;/h2&gt;

&lt;p&gt;Here's the part that still makes me laugh.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;original project&lt;/strong&gt; this stack was built for? I abandoned it. Dead. RIP. Somewhere in a folder with a name I try not to open.&lt;/p&gt;

&lt;p&gt;But HAProxy, ProxySQL, the OVH VPS, the disaster recovery repo, the "wait 30 seconds or you'll think it's broken" wisdom? All of that &lt;strong&gt;got recycled&lt;/strong&gt; into DockSky. Same infrastructure, different dream. Like keeping the engine from a car you totaled and dropping it into something that actually runs.&lt;/p&gt;

&lt;p&gt;So no, I wouldn't recommend building this stack just to abandon the product. But if you're a solo dev who's already paid the tuition in broken healthchecks and angry SQLite databases, you might as well &lt;strong&gt;reuse the homework&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Questions? I'm at &lt;a href="https://docksky.fr" rel="noopener noreferrer"&gt;docksky.fr&lt;/a&gt;. Contact form. No Telegram bot that replies "have you tried turning it off and on again."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Stack: OVH VPS 8 GB · Debian · Docker Compose v2 · Traefik · HAProxy 2.8 · ProxySQL 2.7 · MySQL 8.4 · lots of logs&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>infrastructure</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
