<?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: Amaan Shaikh</title>
    <description>The latest articles on DEV Community by Amaan Shaikh (@amn_shk___).</description>
    <link>https://dev.to/amn_shk___</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%2F2682182%2F73173e00-9e93-41c8-8985-1e60e72fbc72.jpg</url>
      <title>DEV Community: Amaan Shaikh</title>
      <link>https://dev.to/amn_shk___</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amn_shk___"/>
    <language>en</language>
    <item>
      <title>Setting Up the Server: A VPS and Coolify, Start to Finish</title>
      <dc:creator>Amaan Shaikh</dc:creator>
      <pubDate>Wed, 16 Sep 2026 18:37:47 +0000</pubDate>
      <link>https://dev.to/amn_shk___/setting-up-the-server-a-vps-and-coolify-start-to-finish-5h1m</link>
      <guid>https://dev.to/amn_shk___/setting-up-the-server-a-vps-and-coolify-start-to-finish-5h1m</guid>
      <description>&lt;p&gt;Nobody asked me to do this. I want that on the record before anyone assumes there was a memo. There wasn't. I looked at our stack, did some deeply ungentlemanly math on our monthly bill, and decided that if nobody else was going to fix it, I would — quietly, competently, and with slightly too much enthusiasm for firewall rules.&lt;/p&gt;

&lt;p&gt;I wrote about &lt;a href="https://amn-portfolio.vercel.app/notebook/why-i-moved-us-off-vercel-railway-and-appwrite-cloud-onto-our-own-infrastructure" rel="noopener noreferrer"&gt;why we moved off managed platforms&lt;/a&gt; in the last piece. This one is the part where I actually show the work: provisioning a server and getting Coolify running on it, locked down properly, from zero.&lt;/p&gt;

&lt;p&gt;I'm not going to undersell how much "locked down properly" ended up meaning. SSH keys and a firewall are table stakes. What actually made this server production-ready was fail2ban watching for brute-force attempts, and routing both the Coolify dashboard and SSH itself through a Cloudflare Tunnel behind Zero Trust Access — so neither one is reachable from the open internet at all, tunnel or no tunnel. That's most of this guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPS provider account. I used &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; — good specs for the price, but any provider works the same way (DigitalOcean, Linode, Vultr).&lt;/li&gt;
&lt;li&gt;A terminal. I used Git Bash on Windows throughout.&lt;/li&gt;
&lt;li&gt;A domain, with its DNS already sitting on Cloudflare. Everything from the firewall to the tunnel in this guide assumes Cloudflare is your DNS provider, not just a CDN in front of someone else's.&lt;/li&gt;
&lt;li&gt;A Cloudflare account with Zero Trust enabled (the free tier covers everything here).&lt;/li&gt;
&lt;li&gt;About two hours, uninterrupted. This is a longer setup than "spin up a box and SSH in" — rushed server setup is where mistakes creep in, and this time we're not leaving any of them in for you to find later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Generate an SSH key before you provision anything
&lt;/h3&gt;

&lt;p&gt;Do this locally first, so you can attach the public key at server-creation time instead of bolting it on after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/my_server &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"my-server"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a private and public key pair. Keep the private one exactly where it landed — don't let it end up on a Desktop folder that syncs to cloud storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Create the server
&lt;/h3&gt;

&lt;p&gt;Pick a region close to your actual audience, choose Ubuntu 24.04 LTS, and a size with at least 4GB RAM if you plan to run more than one or two services. Paste your SSH key's public half in at creation — most providers have a field for this.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Log in and create a non-root user
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/my_server root@YOUR_SERVER_IP

adduser deploy
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;deploy
rsync &lt;span class="nt"&gt;--archive&lt;/span&gt; &lt;span class="nt"&gt;--chown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deploy:deploy ~/.ssh /home/deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the new user works before doing anything else: open a second terminal and log in as &lt;code&gt;deploy&lt;/code&gt; while your root session stays open, as a safety net.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Lock down SSH
&lt;/h3&gt;

&lt;p&gt;Edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; and set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;PermitRootLogin&lt;/span&gt; &lt;span class="n"&gt;prohibit&lt;/span&gt;-&lt;span class="n"&gt;password&lt;/span&gt;
&lt;span class="n"&gt;PasswordAuthentication&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key-only, no root password login. Restart the service (&lt;code&gt;sudo systemctl restart ssh&lt;/code&gt;), and verify a fresh connection works before closing your existing session.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Add a baseline firewall
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 22/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also set up an edge-level firewall in your provider's dashboard with the same three rules. Two independent layers is the point — one misconfiguration shouldn't mean the server is wide open.&lt;/p&gt;

&lt;p&gt;Do this in the same sitting as creating the server, not "once things are running." Leaving SSH open to the whole internet "for now" isn't a real problem on day one — it's the kind of thing that quietly never gets fixed later either, because there's always something more urgent by the time you remember. We're going to close port 22 to the public internet entirely by the end of this guide anyway, but don't skip the interim step assuming you'll get to the real fix soon.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Install and configure fail2ban
&lt;/h3&gt;

&lt;p&gt;The firewall controls which ports are open. It does nothing about someone hammering port 22 with login attempts once it's open. That's fail2ban's job — it watches your auth logs and temporarily bans IPs after too many failed attempts.&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;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;/etc/fail2ban/jail.local&lt;/code&gt; and confirm the &lt;code&gt;[sshd]&lt;/code&gt; section is enabled, with sane defaults to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[sshd]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;22&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart fail2ban
&lt;span class="nb"&gt;sudo &lt;/span&gt;fail2ban-client status sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last command should show the jail active and watching. Cheap to set up, and it's the layer that actually does something while you're asleep.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Install Coolify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://cdn.coollabs.io/coolify/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Takes a few minutes. It installs Docker if it isn't already there, and sets up its own reverse proxy (Traefik) for automatic SSL on whatever you deploy later.&lt;/p&gt;

&lt;p&gt;One thing to watch for right after install: if Traefik or another proxy throws odd certificate errors as soon as you try to issue SSL for anything, check Docker's IPv6 handling before you assume something's fundamentally broken. It's a known interaction — Docker's default networking can quietly interfere with SSL provisioning — and disabling IPv6 at the Docker daemon level is a five-minute fix once you know to look for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Route the Coolify dashboard and SSH through a Cloudflare Tunnel
&lt;/h3&gt;

&lt;p&gt;Here's where I'll admit to a wrong turn. My first instinct for remote access was a VPN — I travel, and I wanted the dashboard and SSH reachable without punching holes in the firewall for my home IP specifically. Standard mesh-VPN tools turned out to be a bad bet: they can be restricted on certain networks, and in some countries outright, which is a bad thing to discover mid-trip. A Cloudflare Tunnel with Zero Trust Access in front of it does the same job — remote access without exposing anything publicly — more reliably, and honestly with less setup than the VPN route.&lt;/p&gt;

&lt;p&gt;The idea: &lt;code&gt;cloudflared&lt;/code&gt; runs on your server and opens an outbound-only connection to Cloudflare. Nothing needs to be reachable from the internet for this to work — no inbound port, no forwarding. Cloudflare routes traffic to your tunnel, your tunnel routes it to whatever's listening locally.&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;-L&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg &lt;span class="nt"&gt;-i&lt;/span&gt; cloudflared.deb

cloudflared tunnel login
cloudflared tunnel create my-server-tunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last command prints a tunnel ID and writes a credentials file to &lt;code&gt;~/.cloudflared/&lt;/code&gt;. Create the config file:&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;sudo &lt;/span&gt;nano /etc/cloudflared/config.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tunnel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;your-tunnel-id&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;credentials-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/home/deploy/.cloudflared/&amp;lt;your-tunnel-id&amp;gt;.json&lt;/span&gt;

&lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coolify.yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:8000&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssh.yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssh://localhost:22&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http_status:404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters, and that last catch-all line isn't optional — &lt;code&gt;cloudflared&lt;/code&gt; won't start without a final rule that matches anything not already covered. Route the DNS for both hostnames, which creates the CNAME records in Cloudflare automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel route dns my-server-tunnel coolify.yourdomain.com
cloudflared tunnel route dns my-server-tunnel ssh.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then install it as a service so it survives reboots:&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;sudo &lt;/span&gt;cloudflared service &lt;span class="nb"&gt;install
sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; cloudflared
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status cloudflared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the machine you'll actually be connecting &lt;em&gt;from&lt;/em&gt;, install &lt;code&gt;cloudflared&lt;/code&gt; too, and add this to &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; ssh.yourdomain.com
  &lt;span class="k"&gt;User&lt;/span&gt; deploy
  &lt;span class="k"&gt;ProxyCommand&lt;/span&gt; cloudflared access ssh --hostname %h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here, &lt;code&gt;ssh ssh.yourdomain.com&lt;/code&gt; tunnels through Cloudflare instead of hitting the server's public IP directly. Same idea for the dashboard — visiting &lt;code&gt;coolify.yourdomain.com&lt;/code&gt; now routes through the tunnel rather than a direct A record.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Gate both hostnames behind Cloudflare Zero Trust Access
&lt;/h3&gt;

&lt;p&gt;The tunnel alone just moves the traffic — it doesn't check who's on the other end. That's Access's job. In the Zero Trust dashboard, under Access → Applications, create an application for each hostname (&lt;code&gt;coolify.yourdomain.com&lt;/code&gt; and &lt;code&gt;ssh.yourdomain.com&lt;/code&gt;), and attach a policy: allow specific emails or your email domain, require a one-time PIN or your identity provider of choice.&lt;/p&gt;

&lt;p&gt;Two things worth double-checking here, because both will quietly lock you out if you miss them: the hostname in your tunnel config, the DNS record, and the Access application all have to match exactly — a trailing difference between any of them breaks the chain. And an Access application with no policy attached blocks everyone, including you, not just uninvited guests.&lt;/p&gt;

&lt;p&gt;Once both applications are live, hitting either hostname prompts for authentication before the connection is allowed anywhere near your server. The dashboard and SSH are now sitting behind two layers before anyone reaches them: the tunnel itself, and Access in front of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Tighten the firewall now that the tunnel is doing the work
&lt;/h3&gt;

&lt;p&gt;With SSH and the dashboard both reachable through the tunnel, there's no reason to keep them exposed on the public internet too. Pull the SSH rule from both firewall layers:&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;sudo &lt;/span&gt;ufw delete allow 22/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the matching rule from your provider's edge firewall as well. Test the tunnel connection thoroughly before you do this — confirm &lt;code&gt;ssh ssh.yourdomain.com&lt;/code&gt; works cleanly from a fresh terminal first, since this step removes your fallback. What's left open publicly after this is just 80 and 443, for the actual sites Coolify serves. The dashboard and SSH aren't reachable by IP at all anymore, tunnel or nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Turn on backups
&lt;/h3&gt;

&lt;p&gt;Enable your provider's automatic server snapshots before you deploy anything real. It's the cheapest insurance you'll buy all year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where you should be now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A server with no direct public SSH access — reachable only through the Cloudflare Tunnel, gated by Zero Trust Access&lt;/li&gt;
&lt;li&gt;fail2ban active and watching auth attempts&lt;/li&gt;
&lt;li&gt;Only ports 80 and 443 open to the public internet&lt;/li&gt;
&lt;li&gt;Coolify installed, reachable at your own domain through the tunnel, with SSL&lt;/li&gt;
&lt;li&gt;Automatic server backups turned on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these boxes aren't checked, it's worth going back before moving on — everything in the rest of this series assumes this part is solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the series
&lt;/h2&gt;

&lt;p&gt;This is one server doing one thing so far. Here's where it's going:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The server&lt;/strong&gt; — Provisioning a VPS, hardening it, and putting Coolify behind a Cloudflare Tunnel. This part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The frontend&lt;/strong&gt; — Deploying a frontend app to Coolify with zero downtime, alongside wherever it's currently hosted. Framework-agnostic — whatever you're actually running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The backend&lt;/strong&gt; — Self-hosting your backend of choice, and what actually breaks when you do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database&lt;/strong&gt; — Migrating real data without losing any of it, and building a backup habit around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staying in control&lt;/strong&gt; — Monitoring resources, catching problems early, and knowing when it's time to resize.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;The server's ready, and this time it's actually locked down the way it should be. Next part: taking a frontend off a managed platform and running it here instead — without the site ever going down in the process.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>vps</category>
    </item>
    <item>
      <title>Why I Moved Us Off Vercel, Railway, and Appwrite Cloud — Onto Our Own Infrastructure</title>
      <dc:creator>Amaan Shaikh</dc:creator>
      <pubDate>Mon, 31 Aug 2026 16:25:52 +0000</pubDate>
      <link>https://dev.to/amn_shk___/why-i-moved-us-off-vercel-railway-and-appwrite-cloud-onto-our-own-infrastructure-1k72</link>
      <guid>https://dev.to/amn_shk___/why-i-moved-us-off-vercel-railway-and-appwrite-cloud-onto-our-own-infrastructure-1k72</guid>
      <description>&lt;h1&gt;
  
  
  Why I Moved Us Off Vercel, Railway, and Appwrite Cloud — Onto Our Own Infrastructure
&lt;/h1&gt;

&lt;p&gt;Nobody asked me to do this. I want that on the record before anyone assumes there was a memo. There wasn't. I looked at our stack, did some deeply ungentlemanly math on our monthly bill, and decided that if nobody else was going to fix it, I would — quietly, competently, and with slightly too much enthusiasm for firewall rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Three platforms. Three dashboards. Three separate ways for the internet to remind me that convenience has a subscription fee.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; Vercel at $20/mo, Railway at $15/mo, Appwrite Cloud at $25/mo — $60/mo, and it only goes one direction as usage grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits.&lt;/strong&gt; We were bumping into edge and bandwidth caps on Vercel. Real estate listings live or die on image quality, so Asasika's media is heavy by design, not by accident. I wasn't about to compress our listings into looking like they were shot on a flip phone just to fit inside a plan tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency.&lt;/strong&gt; Three billing relationships, three sets of rules I didn't write and couldn't negotiate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bandwidth pressure knocked on the door. The cost is what actually let itself in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision
&lt;/h2&gt;

&lt;p&gt;Move it all onto infrastructure we own: a Hetzner VPS, orchestrated with Coolify, running self-hosted Appwrite, with Cloudflare handling the edge and standing guard on security. One server, one bill, zero platforms telling me what I'm allowed to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What moved
&lt;/h2&gt;

&lt;p&gt;Not a toy project. Real production, real stakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Asasika's real estate database and frontend&lt;/li&gt;
&lt;li&gt;FatPanda Media's website&lt;/li&gt;
&lt;li&gt;Lead automation for Asasika, FatPanda, and our client Wujha&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this had gone sideways, it wouldn't have been a GitHub issue — it would've been a very uncomfortable phone call. So it didn't go sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hetzner CX33 VPS&lt;/strong&gt; — 8.99 GBP/mo (about $12)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coolify (LTS)&lt;/strong&gt; for orchestration and deployment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appwrite, self-hosted (LTS)&lt;/strong&gt; as the backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt;, doing double duty on edge and security:

&lt;ul&gt;
&lt;li&gt;SSH key auth — passwords need not apply&lt;/li&gt;
&lt;li&gt;Firewall locked down to Coolify's IP on its port, and nothing else gets a seat at the table&lt;/li&gt;
&lt;li&gt;Cloudflare Zero Trust guarding both the Coolify dashboard and server SSH&lt;/li&gt;
&lt;li&gt;DNS fully managed through Cloudflare&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Self-hosting without locking it down properly isn't independence, it's just a more expensive way to get hacked. The security layer mattered to me as much as the savings.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it went
&lt;/h2&gt;

&lt;p&gt;Three to four days. Zero downtime — and I'd like a moment of recognition for that, because "zero downtime" is doing a lot of quiet, unglamorous work in that sentence.&lt;/p&gt;

&lt;p&gt;The method: get each service fully running on the new VPS &lt;em&gt;first&lt;/em&gt;, in parallel with the live version, then cut over — never migrate in place and pray. Order mattered too: Asasika's Appwrite backend first, since it was the most urgent and had the most to lose, then its frontend, then FatPanda, then lead automation last, once the pattern had proven itself trustworthy.&lt;/p&gt;

&lt;p&gt;It wasn't friction-free — a round of Docker failures, some firewall trial-and-error, the usual small tax you pay for standing up new infrastructure. The one genuine puzzle: legacy code on the Asasika side referenced storage objects by ID instead of URL, which meant the tidy, textbook migration path politely refused to work. Sorted it — but that's the kind of landmine you only find by actually doing the migration, not by reading the docs and nodding along.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plot twist
&lt;/h2&gt;

&lt;p&gt;The technical side, if I'm honest, went easier than I expected. The actual bottleneck wasn't Docker, or DNS, or my own questionable life choices. It was &lt;em&gt;demand&lt;/em&gt;. The Hetzner VPS model I wanted was popular enough that I had to set up an availability alert just to get one — apparently I wasn't the only builder with this exact idea this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Roughly an 80% cut in infrastructure cost — from $60/mo to about $12/mo — plus full control over limits, security, and architecture that used to belong to somebody else's platform.&lt;/p&gt;

&lt;p&gt;But the number isn't really the headline. The headline is that nobody put this on my to-do list. Good builders don't wait for a budget review to notice the bleeding — they go find it, and they stop it, ideally before anyone else even opens the invoice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm writing up the full walkthrough of this setup — Coolify and self-hosted Appwrite on Hetzner, every step, including the parts that broke. If you're considering the same move, that guide is next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
  </channel>
</rss>
