<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[octave]]></title><description><![CDATA[octave]]></description><link>https://octave.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>octave</title><link>https://octave.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 19 Oct 2026 16:52:52 GMT</lastBuildDate><atom:link href="https://octave.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to resize and watermark product photos with just a URL]]></title><description><![CDATA[TL;DR

What you get: one upload per photo, and every thumbnail, WebP version and watermarked copy from a URL like …/image?w=400&h=400&fit=cover&format=webp.
Who it's for: shops, marketplaces and apps ]]></description><link>https://octave.hashnode.dev/how-to-resize-and-watermark-product-photos-with-just-a-url</link><guid isPermaLink="true">https://octave.hashnode.dev/how-to-resize-and-watermark-product-photos-with-just-a-url</guid><category><![CDATA[Web Development]]></category><category><![CDATA[images]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Mon, 19 Oct 2026 16:36:18 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><strong>What you get:</strong> one upload per photo, and every thumbnail, WebP version and watermarked copy from a URL like <code>…/image?w=400&amp;h=400&amp;fit=cover&amp;format=webp</code>.</li>
<li><strong>Who it's for:</strong> shops, marketplaces and apps that show user or product photos, and don't want to run an image server.</li>
<li><strong>Time:</strong> about 10 minutes in the dashboard.</li>
<li><strong>Cost:</strong> no listed fee for transforms. You pay storage for originals and cached variants (about $0.011 per GB-month at the standard rate).</li>
</ul>
</blockquote>
<p>Every product photo on your site needs four or five sizes: a thumbnail, a card, a full view, a social preview. Most teams either resize on upload with a script that breaks the day design changes a size, or run an image server they then have to patch and scale. Both cost you time, and slow pages cost you sales.</p>
<p>In this guide you'll store each photo once in <strong>Hubfly space Storage</strong> and ask for any size, format or watermark straight from the URL. Your designer can change the card size tomorrow without anyone re-processing a single file.</p>
<p><a href="https://dashboard.hubfly.space"><strong>Try it on Hubfly space →</strong></a> (free tier, no card needed)</p>
<h2>What the feature does</h2>
<p>Hubfly space Storage is S3-compatible object storage. When you register an uploaded file as a <strong>managed asset</strong>, it gets delivery links and an image endpoint. Add query parameters to that endpoint and the service renders the variant on the first request, then caches it in your bucket.</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>What it does</th>
<th>Limits</th>
</tr>
</thead>
<tbody><tr>
<td><code>w</code>, <code>h</code></td>
<td>Output width and height</td>
<td>1–4,096 px each, 16 megapixels max</td>
</tr>
<tr>
<td><code>fit</code></td>
<td><code>inside</code> (default), <code>contain</code>, <code>cover</code>, <code>fill</code></td>
<td>Anything except <code>inside</code> needs both <code>w</code> and <code>h</code></td>
</tr>
<tr>
<td><code>format</code>, <code>quality</code></td>
<td><code>webp</code> (default), <code>jpeg</code>, <code>png</code>; quality 1–100 (default 80)</td>
<td></td>
</tr>
<tr>
<td><code>fx</code>, <code>fy</code></td>
<td>Focal point for <code>cover</code> crops, 0 to 1</td>
<td>Defaults to the centre</td>
</tr>
<tr>
<td><code>rotate</code>, <code>flip</code></td>
<td>0/90/180/270; <code>h</code>, <code>v</code> or <code>hv</code></td>
<td></td>
</tr>
<tr>
<td><code>blur</code>, <code>sharpen</code>, <code>brightness</code>, <code>contrast</code>, <code>saturation</code></td>
<td>Effects</td>
<td>Service-side bounds</td>
</tr>
<tr>
<td><code>wm_text</code> or <code>wm_key</code></td>
<td>One text or image watermark, placed with <code>wm_x</code>, <code>wm_y</code>, <code>wm_opacity</code></td>
<td>Image watermarks must be saved as allowed first</td>
</tr>
</tbody></table>
<p>Full reference: <a href="https://www.hubfly.space/docs/storage/assets-and-images/">Storage assets and images</a>.</p>
<h2>Walkthrough: a product image pipeline in 10 minutes</h2>
<h3>1. Create a Storage project</h3>
<p>In the <a href="https://dashboard.hubfly.space">dashboard</a>, click <strong>Create Project</strong>, name it (for example <code>shop-media</code>), pick the <strong>Storage</strong> platform and choose a region. A Storage project keeps your buckets, keys and delivery settings apart from your app's containers, so you can give the app a key that only touches images.</p>
<h3>2. Create a bucket</h3>
<p>Click <strong>Create bucket</strong> and call it <code>product-images</code>. A bucket is the folder your app will write to; keeping product photos in their own bucket lets you scope a key to just that bucket later.</p>
<h3>3. Upload a photo as a managed file</h3>
<p>Click <strong>Upload files</strong>, drop in a photo and leave <strong>Managed file</strong> ticked. That tick is what registers the file as an asset with delivery links and an image endpoint. A plain S3 object doesn't get them automatically.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/0ac930d8-0743-4c0a-96fa-577874c36efb.png" alt="The Hubfly space Storage upload dialog with the Managed file box ticked for mug-blue.jpg" /></p>
<h3>4. Choose who can see it</h3>
<p>Open the file and go to <strong>Delivery</strong>. New assets start as <strong>Protected</strong>: every link needs the asset's access key or a temporary share link, and a request without one gets <code>401 access key required</code>. For catalogue photos that anyone may see, switch <strong>Link access</strong> to <strong>Public</strong>. Keep customer uploads (IDs, invoices, private galleries) on Protected, and give them random object keys: a Protected viewer page still shows the file name and size to anyone with the link.</p>
<h3>5. Build your image URLs</h3>
<p>Your asset URL looks like <code>https://&lt;storage-node&gt;/product-images/&lt;asset-id&gt;</code>. That address opens a viewer page. For image bytes, add <code>/image</code> and your parameters:</p>
<pre><code class="language-text">https://&lt;storage-node&gt;/product-images/&lt;asset-id&gt;/image?w=400&amp;h=400&amp;fit=cover&amp;format=webp
</code></pre>
<p>The <strong>Transform &amp; Cache</strong> tab has a form for every parameter and a <strong>Preview transformation</strong> button that builds the URL for you.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/420863d2-51dc-492b-8138-69cdb9c84ca6.png" alt="The Transform &amp; Cache tab with fields for width, quality, focal point, effects, watermark position, fit and format" /></p>
<p>Use the URLs in a responsive image tag so each screen downloads only what it needs:</p>
<pre><code class="language-html">&lt;img
  src="https://&lt;storage-node&gt;/product-images/&lt;asset-id&gt;/image?w=800&amp;format=webp"
  srcset="https://&lt;storage-node&gt;/product-images/&lt;asset-id&gt;/image?w=400&amp;format=webp 400w,
          https://&lt;storage-node&gt;/product-images/&lt;asset-id&gt;/image?w=800&amp;format=webp 800w,
          https://&lt;storage-node&gt;/product-images/&lt;asset-id&gt;/image?w=1600&amp;format=webp 1600w"
  sizes="(max-width: 600px) 100vw, 50vw"
  alt="Blue ceramic mug on a wooden table"&gt;
</code></pre>
<p>For a social preview card, ask for the exact shape: <code>?w=1200&amp;h=630&amp;fit=cover&amp;format=jpeg&amp;quality=85</code>.</p>
<h3>6. Add a watermark</h3>
<p>Add text and placement to the same URL. <code>wm_x</code> and <code>wm_y</code> go from 0 to 1, so 0.9/0.9 puts the mark near the bottom-right corner:</p>
<pre><code class="language-text">…/image?w=800&amp;h=500&amp;fit=cover&amp;format=webp&amp;wm_text=ACME%20Shop&amp;wm_opacity=0.6
</code></pre>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/5039ccd5-3d0a-4cbf-96e1-3d5dd21fbe66.png" alt="An 800×500 WebP product photo of a blue mug with an ACME Shop text watermark in the bottom-right corner" /></p>
<p>For a logo instead of text, upload it in <strong>Transform &amp; Cache</strong>, click <strong>Save allowed watermark</strong>, and use <code>wm_key</code>. Only a saved key works in public URLs, so a public link can't pull in any other file as a watermark.</p>
<h3>7. Check the cache</h3>
<p>The same tab lists every cached variant with its size and expiry, and lets you delete one. Identical URLs reuse the cached file instead of creating a new variant. On each image you can keep variants for the bucket default, a custom number of hours, or forever.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/5ee1dc05-a092-4a86-bcb2-2aeb2d5daf51.png" alt="The cache retention setting and the list of 11 cached variants totalling 78 KB" /></p>
<h3>Doing it from your app</h3>
<p>The same flow works through the <a href="https://www.hubfly.space/docs/api/storage/assets-and-images/">Storage API</a>. Here are the routes from the API reference, each under <code>https://api.hubfly.space/api/v1/projects/:projectId/storage/</code>:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Route</th>
<th>Key fields</th>
</tr>
</thead>
<tbody><tr>
<td>Get a short-lived upload URL</td>
<td><code>POST objects/upload-session</code></td>
<td><code>bucket</code>, <code>objectKey</code>, <code>contentType</code> → <code>upload_url</code>, <code>expires_at</code></td>
</tr>
<tr>
<td>Upload the bytes</td>
<td><code>PUT</code> to <code>upload_url</code></td>
<td>before <code>expires_at</code></td>
</tr>
<tr>
<td>Register the asset</td>
<td><code>POST assets/create</code></td>
<td><code>bucket</code>, <code>objectKey</code>, <code>title</code> → asset ID and URL</td>
</tr>
<tr>
<td>Make it public</td>
<td><code>PUT assets/update</code></td>
<td><code>assetId</code>, <code>accessMode: "public"</code></td>
</tr>
<tr>
<td>Share a private file</td>
<td><code>POST assets/share-token</code></td>
<td><code>assetId</code>, <code>ttlSeconds</code></td>
</tr>
</tbody></table>
<p>The upload session matters: your browser code never sees your S3 secret, only a URL that expires.</p>
<h2>What can go wrong</h2>
<ol>
<li><strong>The image comes back at full size.</strong> You added parameters to the viewer page or the <code>/media</code> link. Transforms only work on the <code>/image</code> path.</li>
<li><strong>"unsupported or repeated image parameter".</strong> The service rejects unknown and duplicate parameters. Don't pass your own tracking or cache-busting query strings to image URLs.</li>
<li><strong>"fit requires both dimensions" or a size error.</strong> <code>cover</code>, <code>contain</code> and <code>fill</code> need <code>w</code> and <code>h</code>; each side must be 1–4,096 px and the output 16 MP or less.</li>
</ol>
<p>A fourth one shows up later: each image allows <strong>100 distinct transforms per UTC day</strong>. Pick a fixed set of sizes (say 400, 800, 1600) instead of the exact pixel width of every screen.</p>
<h2>Limits and pricing, honestly (October 2026)</h2>
<ul>
<li><strong>Transforms:</strong> no listed fee. Variants are stored under <code>.hubfly-images/</code> and count toward your storage like any other file.</li>
<li><strong>Storage rate:</strong> the <a href="https://www.hubfly.space/pricing/">pricing page</a> lists standard storage at $0.00001536 per GB-hour. Over a 730-hour month that's 0.00001536 × 730 = <strong>$0.0112 per GB</strong>.</li>
<li><strong>Example:</strong> 2,000 product photos at 1.5 MB is 3.0 GB. Four WebP sizes at an assumed 80 KB each (WebP product shots usually land between 20 and 100 KB) add 2,000 × 4 × 80 KB = 0.64 GB. Total 3.64 GB × $0.0112128 ≈ <strong>$0.04 a month</strong>. A full 100 GB project quota would be about $1.12 a month.</li>
<li><strong>Bandwidth:</strong> delivery is metered and shown under <strong>Activity &amp; usage</strong>, but no per-GB rate is published. Check your Billing page before you serve heavy traffic.</li>
<li><strong>Free tier:</strong> you can sign up and build this without a card; the free tier also includes 360 compute hours a month for your app.</li>
<li><strong>Security:</strong> traffic uses HTTPS, but object bodies are not encrypted at rest. Encrypt sensitive files in your app first.</li>
</ul>
<h2>Who it's not for</h2>
<ul>
<li>You need <strong>AVIF</strong> or animated output. Formats are WebP, JPEG and PNG, and animated sources use the first frame.</li>
<li>You generate <strong>a different size per user or viewport</strong>. The 100-per-day limit will bite; resize on upload instead.</li>
<li>You need <strong>private photos in long-lived embeds</strong>. Protected links rely on an access key or short share links (15 minutes to 24 hours in the dashboard). A dedicated media CDN with signed URLs may suit you better.</li>
</ul>
<h2>Takeaway</h2>
<p>Upload once, describe the size in the URL, and let the cache do the rest. Keep a short list of sizes, keep private files Protected, and watch storage rather than per-image fees.</p>
<p><strong>Read next:</strong> <a href="https://blog.hubfly.space/multi-tenant-saas-isolated-tenants-hubfly-subaccounts?utm_source=hashnode&amp;utm_campaign=resize-watermark-product-photos-url">Isolated SaaS tenants with sub-accounts</a> · <a href="https://blog.hubfly.space/deploy-docker-compose-as-is-8-platforms-prices?utm_source=hashnode&amp;utm_campaign=resize-watermark-product-photos-url">Deploy a docker-compose.yml as-is</a> · <a href="https://blog.hubfly.space/heroku-alternatives-2026-prices?utm_source=hashnode&amp;utm_campaign=resize-watermark-product-photos-url">Best Heroku alternatives in 2026</a></p>
<h2>Go further on Hubfly space</h2>
<ul>
<li><strong>Run the shop itself on Hubfly space Cell:</strong> deploy your app and database from a <a href="https://www.hubfly.space/docs/compose/">Compose file</a> next to your images.</li>
<li><strong>One bucket per customer:</strong> if you sell a SaaS, give each tenant its own space with <a href="https://www.hubfly.space/docs/subaccounts/">sub-accounts</a>.</li>
<li><strong>Video too:</strong> the same Storage project turns uploads into HLS streams at 360p to 1080p (<a href="https://www.hubfly.space/docs/storage/video/">video docs</a>).</li>
</ul>
<p><a href="https://dashboard.hubfly.space"><strong>Start free on Hubfly space, 360 hours included →</strong></a></p>
]]></content:encoded></item><item><title><![CDATA[Best Heroku alternatives in 2026: 7 options and what they cost]]></title><description><![CDATA[Originally published on the Hubfly blog.
Your Heroku bill is a dyno plus a database, and you want to know where to move and what it will cost. Heroku still works, and your app on it is fine today. But]]></description><link>https://octave.hashnode.dev/best-heroku-alternatives-in-2026-7-options-and-what-they-cost</link><guid isPermaLink="true">https://octave.hashnode.dev/best-heroku-alternatives-in-2026-7-options-and-what-they-cost</guid><category><![CDATA[Heroku]]></category><category><![CDATA[Devops]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Cloud]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Mon, 19 Oct 2026 14:40:22 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the <a href="https://blog.hubfly.space/heroku-alternatives-2026-prices?utm_source=hashnode&amp;utm_campaign=heroku-alternatives-2026-prices">Hubfly blog</a>.</em></p>
<p>Your Heroku bill is a dyno plus a database, and you want to know where to move and what it will cost. Heroku still works, and your app on it is fine today. But in February 2026 Heroku said it is moving to a "sustaining engineering" model, centered on upkeep and dependable operation, with new features no longer the priority, and it stopped offering Enterprise contracts to new customers (<a href="https://www.heroku.com/blog/an-update-on-heroku/">Heroku's announcement</a>). Card-paying customers keep the same pricing. Still, if you're starting something new, or your bill keeps growing, it's a good moment to look around.</p>
<p>The problem: most "Heroku alternatives" lists give you a "starting at" price per platform and stop there. That's not what you pay. You pay for a web service <strong>plus</strong> a database, and sometimes a workspace fee on top. This guide prices the same small app everywhere, so the numbers compare.</p>
<p><a href="https://dashboard.hubfly.space"><strong>Try it on Hubfly space</strong></a>: free tier, 360 compute hours a month, no card needed.</p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><strong>What you get:</strong> the monthly cost of the same small app (one always-on web service with 512 MB of RAM plus a Postgres database) on Heroku and 7 alternatives, with every price linked to the vendor's own pricing page (prices as of 19 October 2026).</li>
<li><strong>Who it's for:</strong> anyone running a side project, an internal tool or an early SaaS on Heroku who wants to know where to go and what it will cost.</li>
<li><strong>Time needed:</strong> 10 minutes to read, about 15 minutes to move a small app.</li>
<li><strong>Short answer:</strong> for a managed database, Render and DigitalOcean cost about the same as Heroku. For the lowest bill on a managed platform, Fly.io and Hubfly space land around $7.50 a month, but you run Postgres in a container yourself. If you're happy to be your own ops team, Coolify on one Hetzner server is the cheapest per gigabyte.</li>
</ul>
</blockquote>
<h2>The reference app</h2>
<p>To compare Heroku alternatives fairly, every price below is for the same setup, the one most Heroku apps actually are:</p>
<ul>
<li><strong>1 web service</strong>, always on, about 512 MB of RAM (the size of a Heroku Basic dyno).</li>
<li><strong>1 Postgres database</strong>, small, with at least 1 GB of storage.</li>
<li><strong>730 hours</strong> a month (a full month, always on).</li>
<li>Lowest-priced region where a platform charges differently by region.</li>
</ul>
<p>Why this matters: a price per container alone hides the database, which is often half the bill. And a "free" plan that sleeps after 15 minutes isn't the same product as an always-on one. So the main table is always-on, and free tiers get their own section.</p>
<h2>The comparison table (always on, per month)</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Web service (512 MB)</th>
<th>Postgres</th>
<th>Monthly total</th>
<th>Database is</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Heroku</strong></td>
<td>Basic dyno <a href="https://www.heroku.com/pricing">$7</a></td>
<td>Essential-0, 1 GB <a href="https://www.heroku.com/pricing">$5</a></td>
<td><strong>$12</strong></td>
<td>Managed</td>
</tr>
<tr>
<td><strong>Render</strong></td>
<td>512 MB instance <a href="https://render.com/pricing">$7</a></td>
<td>256 MB RAM, 1 GB SSD <a href="https://render.com/pricing">$6</a></td>
<td><strong>$13</strong></td>
<td>Managed</td>
</tr>
<tr>
<td><strong>DigitalOcean App Platform</strong></td>
<td>1 vCPU, 512 MiB <a href="https://www.digitalocean.com/pricing/app-platform">$5</a></td>
<td>Dev database, 512 MiB <a href="https://www.digitalocean.com/pricing/app-platform">$7</a></td>
<td><strong>$12</strong></td>
<td>Managed (dev tier)</td>
</tr>
<tr>
<td><strong>Railway</strong></td>
<td>0.5 GB RAM, usage-based (<a href="https://railway.com/pricing">rates</a>)</td>
<td>Same, plus 1 GB volume</td>
<td><strong>about $12.33</strong> (see math, quiet app)</td>
<td>You run the template</td>
</tr>
<tr>
<td><strong>Fly.io</strong></td>
<td>shared-cpu-1x, 512 MB <a href="https://docs.fly.io/about/pricing">$3.69</a></td>
<td>Same machine size + 1 GB volume (\(3.69 + \)0.15)</td>
<td><strong>$7.53</strong></td>
<td>You run it</td>
</tr>
<tr>
<td><strong>Fly.io + Managed Postgres</strong></td>
<td><a href="https://docs.fly.io/about/pricing">$3.69</a></td>
<td>Basic <a href="https://docs.fly.io/about/pricing">$38</a> + 1 GB at $0.28</td>
<td><strong>$41.97</strong></td>
<td>Managed</td>
</tr>
<tr>
<td><strong>Hubfly space</strong></td>
<td>1 vCPU, 512 MB, always on: $3.71</td>
<td>Same size + 1 GB volume: $3.72</td>
<td><strong>$7.43</strong> (see math)</td>
<td>You run it</td>
</tr>
<tr>
<td><strong>Koyeb</strong></td>
<td>eco-micro, 0.25 vCPU, 512 MB <a href="https://www.koyeb.com/docs/reference/instances">$2.68</a></td>
<td>small, 1 GB RAM <a href="https://www.koyeb.com/docs/databases">$29.76</a> + 1 GB at $0.50</td>
<td><strong>$32.94</strong></td>
<td>Managed</td>
</tr>
<tr>
<td><strong>Coolify on Hetzner CX23</strong></td>
<td>One server, 2 vCPU, 4 GB: <a href="https://docs.hetzner.com/general/infrastructure-and-availability/price-adjustment/">$6.49</a> excl. IPv4</td>
<td>Runs on the same server</td>
<td><strong>$6.49 + IPv4</strong></td>
<td>You run it, and the server</td>
</tr>
</tbody></table>
<p>All prices were read on the vendors' official pages on 19 October 2026, in USD, before tax. Prices change. Click through before you decide.</p>
<h3>How the usage-based numbers are worked out</h3>
<p><strong>Hubfly space</strong> bills per resource: \(0.004 per vCPU-hour, \)0.002 per GB of RAM per hour, and $0.00001536 per GB of storage per hour (<a href="https://www.hubfly.space/pricing">pricing</a>). For one always-on dedicated container with 1 vCPU, 512 MB of RAM and 5 GB of disk:</p>
<ul>
<li>CPU: 1 × \(0.004 × 730 h = \)2.92</li>
<li>RAM: 0.5 GB × \(0.002 × 730 h = \)0.73</li>
<li>Disk: 5 GB × \(0.00001536 × 730 h = \)0.056</li>
<li><strong>Total: $3.71 a month per container</strong></li>
</ul>
<p>Two of those (web and Postgres) come to $7.41. The Postgres volume adds 1 GB on Balanced storage (1.5× the base rate): 1 × $0.00001536 × 1.5 × 730 = $0.017. <strong>Grand total: $7.43.</strong> The Stack Composer's <strong>Monthly Estimate</strong> prices each service as you set it up, before you deploy (screenshots below). Drop each container to 0.5 vCPU and the total falls to about $4.51.</p>
<p><strong>Railway</strong> bills what your services actually use, per second: $0.00000386 per GB of RAM and $0.00000772 per vCPU (<a href="https://railway.com/pricing">pricing</a>). A 730-hour month is 2,628,000 seconds. Assume each service holds 0.5 GB of RAM and averages 0.05 vCPU (a quiet app):</p>
<ul>
<li>RAM: 0.5 × \(0.00000386 × 2,628,000 = \)5.07 per service</li>
<li>CPU: 0.05 × \(0.00000772 × 2,628,000 = \)1.01 per service</li>
<li>Two services: $12.17, plus a 1 GB volume at $0.16 = <strong>about $12.33</strong></li>
</ul>
<p>The Hobby plan's $5 minimum is included in that, not added. A busier app costs more; one that idles at 200 MB costs less. That's the point of usage pricing, and also why you can't know the bill in advance.</p>
<p><strong>Fly.io</strong> prices above are for Ashburn (<code>iad</code>), one of its two lowest-priced regions. Fly lists higher prices elsewhere; a 1 GB machine is $6.70 in Ashburn and $7.73 in Frankfurt (<a href="https://docs.fly.io/about/pricing">pricing</a>).</p>
<h2>Free tiers: what you get, and the catch</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Free web service</th>
<th>Free database</th>
<th>The catch</th>
</tr>
</thead>
<tbody><tr>
<td>Heroku</td>
<td>None. Eco is <a href="https://www.heroku.com/pricing">$5</a></td>
<td>None</td>
<td>Eco dynos sleep after 30 minutes of inactivity</td>
</tr>
<tr>
<td>Render</td>
<td>Yes, 750 hours a month (<a href="https://render.com/docs/free">docs</a>)</td>
<td>Yes</td>
<td>Web spins down after 15 minutes without traffic and takes about a minute to wake. Free Postgres <strong>expires after 30 days</strong>, with a 14-day grace period</td>
</tr>
<tr>
<td>Railway</td>
<td>30-day trial with $5 credit, then $1 a month (<a href="https://railway.com/pricing">pricing</a>)</td>
<td>Within credit</td>
<td>0.5 GB RAM per service and 0.5 GB of volume on Free</td>
</tr>
<tr>
<td>Fly.io</td>
<td>Trial only: 2 hours of machine time or 7 days (<a href="https://docs.fly.io/about/pricing">pricing</a>)</td>
<td>No</td>
<td>Pay as you go after that</td>
</tr>
<tr>
<td>DigitalOcean</td>
<td>Static sites only, up to 3 apps (<a href="https://docs.digitalocean.com/products/app-platform/details/pricing/">pricing</a>)</td>
<td>No</td>
<td>No free container</td>
</tr>
<tr>
<td>Koyeb</td>
<td>1 web service, Frankfurt or Washington (<a href="https://www.koyeb.com/docs/reference/instances">instances</a>)</td>
<td>1 database, 5 hours of active time, 1 GB (<a href="https://www.koyeb.com/docs/faqs/pricing">FAQ</a>)</td>
<td>Free instance scales to zero after 1 hour without traffic</td>
</tr>
<tr>
<td>Hubfly space</td>
<td>Shared tier, 360 compute hours a month, no card (<a href="https://www.hubfly.space/pricing">pricing</a>)</td>
<td>Postgres runs as a container on the same free hours</td>
<td>Shared containers sleep when idle. Volumes are still billed (1 GB is about $0.02 a month)</td>
</tr>
</tbody></table>
<p>If your app must answer instantly at 3 a.m., none of the free tiers is for you. Pay for always-on, on any platform.</p>
<h2>Where each platform wins</h2>
<p>A fair comparison says where the others are better.</p>
<p><strong>Heroku</strong> is still the original "push and forget" experience, with a large <a href="https://elements.heroku.com/addons">add-on marketplace</a>. Card customers keep the same prices. Stay if your app is stable and the bill doesn't hurt; the main risk is fewer new features from here on.</p>
<p><strong>Render</strong> works the most like Heroku: managed Postgres, the same web-service model, and a free tier for experiments. You pay a little more than Heroku for the same setup, and $0.15 per GB after 5 GB of bandwidth on Hobby (<a href="https://render.com/pricing">pricing</a>).</p>
<p><strong>DigitalOcean App Platform</strong> matches Heroku's price with a cheaper web container ($5) and a $7 dev database. Bandwidth overage is only $0.02 per GiB. Good if you already use DigitalOcean.</p>
<p><strong>Railway</strong> has a visual project canvas and bills by the second for what you really use. Great for spiky or idle apps. Harder to predict a monthly bill.</p>
<p><strong>Fly.io</strong> wins on regions: you can run the same app close to users on several continents, at $3.69 for a 512 MB machine in Ashburn (<a href="https://docs.fly.io/about/pricing">pricing</a>). Its managed Postgres is the most expensive line in this table, so most small apps run their own.</p>
<p><strong>Koyeb</strong> has the cheapest small container here ($2.68 for 512 MB). Its managed Postgres starts at $29.76 a month, so it's cheapest when the database lives elsewhere.</p>
<p><strong>Coolify on Hetzner</strong> gives you the most server for the money: 2 vCPU and 4 GB of RAM for $6.49. Coolify itself is <a href="https://coolify.io/pricing">free to self-host</a>. But you're the ops team: OS updates, backups, monitoring and the 3 a.m. disk-full alert are yours. One more catch on 19 October 2026: Hetzner's <a href="https://www.hetzner.com/cloud/cost-optimized/">cost-optimized page</a> listed the CX23 as "currently unavailable".</p>
<p><strong>Hubfly space</strong> lands next to Fly.io on price ($7.43 always on, $4.51 at half a vCPU), with no per-seat, per-project or custom-domain fee. You can also set a monthly budget cap per project, so a runaway app stops instead of surprising you. Where it's weaker today: Postgres runs as a container you manage, and Hubfly doesn't yet document database backups or snapshots. If you need to run in many regions at once, Fly.io is the stronger pick.</p>
<h2>Moving a Heroku app to Hubfly space in about 15 minutes</h2>
<p>Here's the path for the reference app: a web service and Postgres.</p>
<p><strong>1. Create a project.</strong> In the <a href="https://dashboard.hubfly.space">dashboard</a>, click <strong>Create Project</strong>, pick <strong>Cell</strong>, choose a region and, under <strong>Advanced Options</strong>, set a <strong>Monthly Budget Limit</strong> (for example $10). A budget cap means a bug can't run up a big bill.</p>
<p><strong>2. Describe your app in a Compose file.</strong> Heroku's Procfile and add-ons become two services. Replace <code>your-image</code> with your app's image, or deploy from Git (Hubfly builds from a root <code>Dockerfile</code> or detects your stack from package files).</p>
<pre><code class="language-yaml">services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: notes
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "app", "-d", "notes"]
      interval: 5s
      timeout: 3s
      retries: 10
  web:
    image: your-image
    environment:
      DATABASE_URL: postgres://app:change-me@{{service:db.host}}:5432/notes
    ports:
      - "80:80"
    depends_on:
      db:
        condition: service_healthy
volumes:
  pgdata:
</code></pre>
<p>Replace <code>change-me</code> with a long random password (for example from <code>openssl rand -hex 24</code>) and keep the real value out of Git: set it in the service's environment variables in the dashboard.</p>
<p>Why the volume: it keeps your database files when the container restarts or redeploys. Without it, every update would wipe your data. Why <code>{{service:db.host}}</code>: Hubfly fills in the database's private address, so the app never goes over the public internet to reach Postgres.</p>
<p><strong>3. Import and check the price.</strong> Click <strong>Create → Compose Stack</strong>, open the <strong>YAML</strong> tab, paste the file and click <strong>Import YAML</strong>. The <strong>Monthly Estimate</strong> shows the cost before anything runs. Both services start on the free shared tier.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/4d5376d8-fc91-4a4b-977f-7f8b942ff11b.jpg" alt="Hubfly Stack Composer with the db and web services imported on the shared tier, Monthly Estimate $0.01" /></p>
<p><strong>4. Choose always-on, if you need it.</strong> Click the <code>web</code> service, open <strong>Resources</strong> and switch <strong>Service tier</strong> to <strong>Dedicated (Production)</strong>. The default is 1 vCPU, 512 MB and 5 GB, with <strong>24x7 On</strong>. The estimate updates right away.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/ffc6e815-52a0-4864-91fb-4ec8c4cd19be.jpg" alt="The same stack with the web service on the dedicated tier, Monthly Estimate $3.72" /></p>
<p><strong>5. Deploy.</strong> Click <strong>Deploy Stack</strong>. The volume is created, Postgres deploys first, then the web service. The whole stack is up in under a minute.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/ea158e0f-8f2f-4f6d-98c2-0ba9c0ba6e3d.jpg" alt="Deployment log: volume ready, db deployed, service condition satisfied, web deployed, stack deployment finished in 39 seconds" /></p>
<p><strong>6. Give it a public URL.</strong> Open the <code>web</code> service, then <strong>Network → Add Domain → Auto Endpoint</strong> and click <strong>Create Subdomain</strong>. You get an HTTPS address like <code>your-word.eu1.hubfly.app</code> within seconds. The <code>ports: "80:80"</code> line only gives a raw TCP address; the Auto Endpoint is what gives you HTTPS. Add your own domain the same way under <strong>Custom Domain</strong>.</p>
<p><strong>7. Move your data.</strong> Dump from Heroku with <code>heroku pg:backups:capture</code> and <code>heroku pg:backups:download</code>, then open a <a href="https://www.hubfly.space/docs/tunnels/">HubTunnel</a> to the new database (<code>hubfly tunnel create --project &lt;project-id&gt; --container &lt;db-container-id&gt; --port 5432 --local 15432</code>) and restore: <code>pg_restore --no-owner --no-acl -h 127.0.0.1 -p 15432 -U app -d notes latest.dump</code>. Switch your DNS when the data is in.</p>
<h2>What can go wrong</h2>
<p><strong>1. The app returns 502 Bad Gateway.</strong> Heroku sets <code>PORT</code> for you and your app listens on it. On Hubfly, the app must listen on <code>0.0.0.0</code> and on the port you expose. An app bound to <code>127.0.0.1</code> gives a 502. Fix: bind to <code>0.0.0.0</code> (<a href="https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&amp;utm_campaign=heroku-alternatives-2026-prices">full fix</a>).</p>
<p><strong>2. <code>ports: "80:80"</code> gives you a TCP address, not a website.</strong> Importing a Compose file with <code>ports</code> creates a raw TCP endpoint on <code>tcp.eu1.hubfly.app</code>. For HTTPS, add an <strong>Auto Endpoint</strong> or a custom domain as in step 6.</p>
<p><strong>3. The web service starts before the database is ready.</strong> Don't count on <code>depends_on: condition: service_healthy</code> to hold the web service back. Add a connection retry with backoff in your app, so it keeps trying until Postgres accepts connections, and again after any restart (<a href="https://blog.hubfly.space/docker-health-checks-postgres-redis?utm_source=hashnode&amp;utm_campaign=heroku-alternatives-2026-prices">health checks explained</a>).</p>
<p>Bonus: Hubfly doesn't read a Procfile. Put your start command in the Dockerfile or in <strong>Configuration → Process Settings</strong>.</p>
<h2>Takeaway</h2>
<ul>
<li><strong>Want a managed Postgres and no ops?</strong> Render (about $13) or DigitalOcean (about $12). Same price range as Heroku.</li>
<li><strong>Want the lowest bill on a managed platform, and can run Postgres in a container?</strong> Fly.io ($7.53) or Hubfly space ($7.43, or $4.51 at half a vCPU), with a budget cap on Hubfly.</li>
<li><strong>Want the most server per dollar and don't mind ops?</strong> Coolify on Hetzner ($6.49 plus IPv4).</li>
<li><strong>Bill depends on traffic and you like per-second pricing?</strong> Railway.</li>
<li><strong>Just need the cheapest container, with the database elsewhere?</strong> Koyeb ($2.68).</li>
</ul>
<p>Read next: <a href="https://blog.hubfly.space/heroku-vs-render-vs-railway-vs-hubfly-pricing?utm_source=hashnode&amp;utm_campaign=heroku-alternatives-2026-prices">Heroku vs Render vs Railway vs Hubfly: 4 prices for one app + Postgres</a>, <a href="https://blog.hubfly.space/deploy-docker-compose-as-is-8-platforms-prices?utm_source=hashnode&amp;utm_campaign=heroku-alternatives-2026-prices">Deploy a docker-compose.yml as-is: 8 platforms compared</a> and <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=heroku-alternatives-2026-prices">Gate your app on a Postgres migration job</a>.</p>
<h2>Go further on Hubfly space</h2>
<ul>
<li><strong>Ship a new version to 10% of users first</strong> with a weighted load balancer: <a href="https://blog.hubfly.space/canary-release-weighted-load-balancer?utm_source=hashnode&amp;utm_campaign=heroku-alternatives-2026-prices">canary releases guide</a>.</li>
<li><strong>Keep uploads and images out of your app container</strong> with S3-compatible <a href="https://www.hubfly.space/docs/">Storage</a> buckets and on-the-fly image resizing.</li>
<li><strong>Lock an admin panel behind a login</strong> at the edge with <a href="https://www.hubfly.space/docs/identity-access/">Identity Access</a>, no code changes.</li>
</ul>
<p><strong>Moving from Heroku?</strong> Paste your stack into the Stack Composer and see the monthly estimate before you deploy anything. <a href="https://dashboard.hubfly.space">Start free on Hubfly space</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Docker health checks that catch real problems, explained simply]]></title><description><![CDATA[A plain guide to Docker health checks for your app, Postgres (pg_isready) and Redis, and three mistakes that make a check pass when it shouldn't.
Your app starts, tries to reach the database, and cras]]></description><link>https://octave.hashnode.dev/docker-health-checks-that-catch-real-problems-explained-simply</link><guid isPermaLink="true">https://octave.hashnode.dev/docker-health-checks-that-catch-real-problems-explained-simply</guid><category><![CDATA[Docker]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Redis]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Mon, 19 Oct 2026 09:30:57 GMT</pubDate><content:encoded><![CDATA[<p><em>A plain guide to Docker health checks for your app, Postgres (pg_isready) and Redis, and three mistakes that make a check pass when it shouldn't.</em></p>
<p>Your app starts, tries to reach the database, and crashes because Postgres is still waking up. Or worse: everything looks "healthy", but Redis is rejecting every request because of a password typo. A health check is a small command that answers one question, over and over: <em>is this service actually ready to do its job?</em> Get it right and you can see when a service is truly ready, broken ones are easy to spot, and your users stop seeing errors during deploys.</p>
<p>In this guide you'll add working health checks to a web app, Postgres and Redis, deploy them as one stack, and learn the three mistakes that make a health check say "all good" when it isn't.</p>
<p><a href="https://dashboard.hubfly.space/?utm_source=blog&amp;utm_medium=post&amp;utm_campaign=health-checks"><strong>Try it on Hubfly space</strong></a>: the whole stack runs on the free shared tier (360 hours a month, no card needed).</p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><strong>What you get:</strong> a Compose file with checks for Postgres (<code>pg_isready</code>), Redis (<code>redis-cli ping | grep -q PONG</code>) and a web app (<code>wget</code>, shown with nginx), deployed in dependency order.</li>
<li><strong>Who it's for:</strong> anyone running more than one container, from side projects to small production apps.</li>
<li><strong>Time:</strong> about 15 minutes. The stack deploys in about a minute.</li>
<li><strong>The three traps:</strong> <code>redis-cli</code> exits 0 even when the password is wrong; <code>pg_isready</code> can pass while Postgres is still running its first-boot scripts; and the image may not contain the tool your check calls.</li>
</ul>
</blockquote>
<h2>What a health check is (and what it isn't)</h2>
<p>A health check is a command that runs <strong>inside</strong> the container on a timer. Exit code <code>0</code> means healthy. Anything else counts as a failure. After a set number of failures in a row, the container is marked unhealthy.</p>
<p>Every check has four timing settings:</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>What it means</th>
<th>Docker default</th>
</tr>
</thead>
<tbody><tr>
<td><code>interval</code></td>
<td>How often the check runs</td>
<td>30s</td>
</tr>
<tr>
<td><code>timeout</code></td>
<td>How long one run may take before it counts as failed</td>
<td>30s</td>
</tr>
<tr>
<td><code>retries</code></td>
<td>Failures in a row before "unhealthy"</td>
<td>3</td>
</tr>
<tr>
<td><code>start_period</code></td>
<td>Grace time after start when failures don't count</td>
<td>0s</td>
</tr>
</tbody></table>
<p>Two things a health check does <strong>not</strong> do on its own:</p>
<ul>
<li><strong>It doesn't restart anything.</strong> A container that fails its check keeps running. Restart policies react to a process <em>exiting</em>, not to a failed check.</li>
<li><strong>It doesn't fix your app's startup.</strong> It tells other services, and you, when something is ready. Your app should still retry its database connection for a few seconds on startup, because networks blink.</li>
</ul>
<p>Why it matters: without checks, "the container is running" is the only signal you have, and a running container can be completely useless.</p>
<h2>Step 1: Write a check for Postgres</h2>
<pre><code class="language-yaml">db:
  image: postgres:16-alpine
  environment:
    POSTGRES_USER: shop
    POSTGRES_PASSWORD: change-me
    POSTGRES_DB: shop
  volumes:
    - pgdata:/var/lib/postgresql/data
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U shop -d shop"]
    interval: 5s
    timeout: 3s
    retries: 5
    start_period: 20s
</code></pre>
<p>Why each piece is there:</p>
<ul>
<li><strong><code>pg_isready</code></strong> ships with the Postgres image, so there's nothing to install. It asks the server "are you accepting connections?" and exits 0 when the answer is yes.</li>
<li><strong><code>-h 127.0.0.1</code></strong> is the important bit. On first boot, the official image starts a temporary Postgres that listens <strong>only on a local socket</strong> while it runs your init scripts. A plain <code>pg_isready</code> checks that socket and can pass too early. Forcing TCP with <code>-h 127.0.0.1</code> waits for the real server.</li>
<li><strong><code>start_period: 20s</code></strong> gives a fresh database time to initialize before failures count.</li>
<li><strong>The volume</strong> keeps your data when the container restarts or redeploys. Without it, every update would start from an empty database.</li>
</ul>
<h2>Step 2: Write a check for Redis (and avoid the exit-0 trap)</h2>
<pre><code class="language-yaml">cache:
  image: redis:7-alpine
  environment:
    REDIS_PASSWORD: change-me
  command: ["sh", "-c", "exec redis-server --requirepass \"$REDIS_PASSWORD\""]
  healthcheck:
    test: ["CMD-SHELL", "REDISCLI_AUTH=\"$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"]
    interval: 5s
    timeout: 3s
    retries: 5
</code></pre>
<p>This is the syntax for the Hubfly space Stack Composer. In a local <code>docker compose</code> file, write <code>$$REDIS_PASSWORD</code> instead (see "What can go wrong" #2).</p>
<p>Most examples online use <code>redis-cli ping</code> on its own. That check passes even when Redis is refusing you. Run it against a password-protected Redis and look at the exit code:</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/33ea614d-462b-4427-b2e1-b18965a724fe.png" alt="Console output: redis-cli ping without a password prints NOAUTH and still exits 0; with the wrong password it prints WRONGPASS and still exits 0" /></p>
<p><code>redis-cli</code> prints the error and <strong>still exits 0</strong>. Your check would say "healthy" while every real request fails. The fix is to check for the actual answer: <code>| grep -q PONG</code> exits 1 unless Redis really replied <code>PONG</code>.</p>
<p>Why <code>REDISCLI_AUTH</code> instead of <code>-a password</code>: <code>redis-cli</code> reads the password from this variable, so it doesn't show up in the process list or the shell history.</p>
<p>Why <code>exec</code> in the command: it makes <code>redis-server</code> the main process, so it receives stop signals directly and shuts down cleanly.</p>
<h2>Step 3: Write a check for your web app</h2>
<p>Add a small <code>/health</code> route to your app that returns 200 when it can reach what it needs. Then check it from inside the container:</p>
<pre><code class="language-yaml">web:
  image: your-app:latest
  healthcheck:
    test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/health || exit 1"]
    interval: 10s
    timeout: 3s
    retries: 3
    start_period: 15s
  depends_on:
    db:
      condition: service_healthy
    cache:
      condition: service_healthy
</code></pre>
<p>Two things to get right:</p>
<ul>
<li><strong>Use a tool the image actually has.</strong> Alpine-based images include <code>wget</code> (from BusyBox); many, like <code>redis:7-alpine</code>, have no <code>curl</code>, while <code>nginx:alpine</code> has both. Run <code>which curl wget</code> in the container to be sure. Slim Debian images (<code>python:3.13-slim</code>, <code>node:22-slim</code>) have neither. For those, call your runtime instead, for example <code>python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:3000/health', timeout=2)"</code>. Distroless and <code>scratch</code> images have no shell at all, so build a tiny check into your app binary.</li>
<li><strong>Check <code>127.0.0.1</code>, not your public URL.</strong> The check runs inside the container. Testing the public URL measures your proxy and DNS, not your app.</li>
</ul>
<p><code>depends_on</code> with <code>condition: service_healthy</code> sets the order: <code>db</code> and <code>cache</code> first, <code>web</code> last. In local Docker Compose it also holds <code>web</code> back until both checks pass. On Hubfly space, the order is the same, but don't count on the gate to wait for a passing check: a dependency can be marked ready within seconds of deploying. Your app's connection retry is what actually protects you, so keep it.</p>
<h2>Step 4: Deploy the stack on Hubfly space</h2>
<ol>
<li>In the <a href="https://dashboard.hubfly.space/?utm_source=blog&amp;utm_medium=post&amp;utm_campaign=health-checks">dashboard</a>, click <strong>Create Project</strong>. Name it (for example <code>shop-api</code>), pick a region, and optionally open <strong>Advanced Options</strong> to set a monthly budget cap such as $10.</li>
<li>In the project, click <strong>Create → Compose Stack</strong>, open the <strong>YAML</strong> tab, paste the full file below, and click <strong>Import YAML</strong>.</li>
<li>Click <strong>Deploy Stack</strong>.</li>
</ol>
<p>The file uses <code>nginx:alpine</code> as a stand-in web server so you can deploy it exactly as written. To use your own app, change <code>image</code> and point the check at your <code>/health</code> route and port, for example <code>http://127.0.0.1:3000/health</code>.</p>
<pre><code class="language-yaml">services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: shop
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U shop -d shop"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 20s
  cache:
    image: redis:7-alpine
    environment:
      REDIS_PASSWORD: change-me
    command: ["sh", "-c", "exec redis-server --requirepass \"$REDIS_PASSWORD\""]
    healthcheck:
      test: ["CMD-SHELL", "REDISCLI_AUTH=\"$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"]
      interval: 5s
      timeout: 3s
      retries: 5
  web:
    image: nginx:alpine
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 15s
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
volumes:
  pgdata:
</code></pre>
<p>Replace <code>change-me</code> with real passwords before you deploy anything that matters. Note that <code>--requirepass</code> on the command line is visible in the container's process list; for production, put the password in a Redis config file on a volume instead.</p>
<p>The deploy log shows the order: the volume first, then <code>db</code>, then <code>cache</code>, and <code>web</code> last, once both dependencies have deployed. The whole stack takes about a minute.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/86f941f8-0fd7-47b5-9aa5-064e67a99268.png" alt="The Compose deployment log: volume ready, db deployed in stage 1, cache in stage 2, and web waiting for db before it deploys" /></p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/7b3259a1-64fe-4b8f-a166-4e1b2848398f.png" alt="The project canvas with db, cache and web running on the internal network" /></p>
<h2>Step 5: Make sure each check can fail</h2>
<p>Don't trust a check you've never seen fail. Open each container, go to the <strong>Console</strong> tab, and run its test command by hand:</p>
<pre><code class="language-bash"># In the db container
pg_isready -h 127.0.0.1 -U shop -d shop; echo "check exit=$?"
pg_isready -h 127.0.0.1 -p 5433; echo "wrong port exit=$?"

# In the cache container
sh -c 'REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli ping | grep -q PONG'; echo "check exit=$?"
redis-cli ping | grep -q PONG; echo "without password exit=$?"

# In the web container
wget -q -O /dev/null http://127.0.0.1/; echo "check exit=$?"
wget -q -O /dev/null http://127.0.0.1/missing; echo "missing page exit=$?"
</code></pre>
<p>You want <code>0</code> for each real check and a non-zero code for each deliberately broken one. Now you know the check can tell the difference.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/10fc86bf-6615-4cf7-84de-dfdc058ff386.png" alt="Console output in the db container: pg_isready accepts connections with exit 0, and gets no response with exit 2 on a wrong port" /></p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/63d9d2ce-ff63-4188-8942-ee54c9596388.png" alt="Console output in the cache container: the real check exits 0, the check without a password exits 1, and the image has wget but no curl" /></p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/6c4fe6db-564c-4eba-8cd8-76532288e53b.png" alt="Console output in the web container: the check exits 0, and a missing page returns 404 and exits 1" /></p>
<p>The container's status badge doesn't always show the check result, so the Console is the reliable place to look.</p>
<p>You can change a check later on a single container: open it, go to <strong>Configuration → Health Check → Edit</strong>, update the test command, interval, timeout, start period or retries, and click <strong>Save Changes</strong>. The container restarts with the new settings.</p>
<p><img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/45b1c5e4-5132-4fb3-b47a-a914043e13c6.png" alt="The Health Check editor on a container's Configuration tab" /></p>
<h2>What can go wrong</h2>
<p><strong>1. The check passes, but the service is broken.</strong> Classic cause: <code>redis-cli ping</code> without <code>| grep -q PONG</code>, or an HTTP check that treats any response as success. Fix: check for the exact good answer, then prove it fails by running a broken version by hand (Step 5).</p>
<p><strong>2. Your password variable turns into nonsense.</strong> In a local <code>docker compose</code> file you must write <code>$$REDIS_PASSWORD</code>, because Compose replaces single <code>$</code> variables with values from your laptop's environment. The Hubfly space Stack Composer passes your command to the shell exactly as written, so there you write a single <code>$REDIS_PASSWORD</code>. If you paste <code>$$REDIS_PASSWORD</code> into Hubfly, the shell reads <code>$$</code> as its process ID and Redis ends up with a password like <code>1REDIS_PASSWORD</code>. The symptom: your app gets <code>WRONGPASS</code> with the password you set. Worse, a failing check doesn't stop the stack from deploying, and the container keeps running, so nothing looks wrong at first. Fix: use <code>$</code> on Hubfly, <code>$$</code> locally, and check with <code>REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli ping</code> in the Console.</p>
<p><strong>3. "executable file not found" or the check always fails.</strong> The image doesn't contain <code>curl</code>, <code>wget</code> or even a shell. Fix: run <code>which curl wget</code> in the Console, then pick a tool the image has (Step 3). For <code>scratch</code> or distroless images, add a <code>/health</code> subcommand to your binary and use the exec form: <code>["CMD", "/app", "health"]</code>.</p>
<h2>Takeaway</h2>
<p>A health check is only useful if it can fail. Check for the real answer (<code>PONG</code>, a 200 from <code>/health</code>, <code>pg_isready</code> over TCP), give slow starters a <code>start_period</code>, use a tool the image actually contains, and run every check by hand once. Pair that with a connection retry in your app, and deploys stop being the moment your users see errors.</p>
<p>Related guides:</p>
<ul>
<li><a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=docker-health-checks-postgres-redis">Run a database migration before your app starts</a>, the next step after health checks.</li>
<li><a href="https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&amp;utm_campaign=docker-health-checks-postgres-redis">Why your app shows a 502 error in Docker (and how to fix it)</a></li>
<li><a href="https://blog.hubfly.space/deploy-docker-compose-as-is-8-platforms-prices?utm_source=hashnode&amp;utm_campaign=docker-health-checks-postgres-redis">Deploy a docker-compose.yml as-is</a></li>
</ul>
<h2>Deploy this</h2>
<p>Paste the Compose file from Step 4 into a new project on <a href="https://dashboard.hubfly.space/?utm_source=blog&amp;utm_medium=post&amp;utm_campaign=health-checks">Hubfly space</a>. On the free shared tier, the dashboard projects about $0.02 a month for this stack. That's the 1 GB database volume; shared runtime is free within 360 hours a month, and sleeping containers use no compute.</p>
<h2>Go further on Hubfly space</h2>
<ul>
<li><strong>Send traffic only to healthy copies of your app:</strong> put a <a href="https://www.hubfly.space/docs/load-balancers/">load balancer</a> in front of two or more <code>web</code> containers. It uses health probes to route around a target that stops answering.</li>
<li><strong>Ship a release slowly:</strong> send 10% of traffic to a new version first with a <a href="https://blog.hubfly.space/canary-release-weighted-load-balancer?utm_source=hashnode&amp;utm_campaign=docker-health-checks-postgres-redis">weighted load balancer</a>.</li>
<li><strong>Reach your database from your laptop without a public port:</strong> open a short-lived <a href="https://www.hubfly.space/docs/tunnels/">tunnel</a> with <code>hubfly tunnel create</code>.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Deploy a docker-compose.yml as-is: 8 platforms, which keys survive, and what web + Postgres + Redis costs]]></title><description><![CDATA[Originally published on the Hubfly blog.

TL;DR

What you get: which platforms take your docker-compose.yml without a rewrite, which Compose keys survive, and a monthly price for the same 3-service st]]></description><link>https://octave.hashnode.dev/deploy-a-docker-compose-yml-as-is-8-platforms-which-keys-survive-and-what-web-postgres-redis-costs</link><guid isPermaLink="true">https://octave.hashnode.dev/deploy-a-docker-compose-yml-as-is-8-platforms-which-keys-survive-and-what-web-postgres-redis-costs</guid><category><![CDATA[Docker]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Cloud Computing]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Sun, 18 Oct 2026 16:16:49 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the</em> <a href="https://blog.hubfly.space/deploy-docker-compose-as-is-8-platforms-prices?utm_source=hashnode&amp;utm_campaign=deploy-docker-compose-as-is-8-platforms-prices"><em>Hubfly blog</em></a><em>.</em></p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><p><strong>What you get:</strong> which platforms take your <code>docker-compose.yml</code> without a rewrite, which Compose keys survive, and a monthly price for the same 3-service stack (web + Postgres + Redis) on 8 platforms.</p>
</li>
<li><p><strong>Who it's for:</strong> you have a Compose file that works on your laptop and you want it online without learning a new config format.</p>
</li>
<li><p><strong>Time:</strong> 10 minutes to read. 2 minutes to try it on Hubfly.</p>
</li>
<li><p><strong>Short answer:</strong> Hubfly runs the file as-is; Coolify's docs say it runs Compose natively; Railway imports it (some keys may not carry over). Render, DigitalOcean App Platform, Heroku and Koyeb want a rewrite. Cheapest always-on stack: about \(6–12/month if you run Postgres yourself, \)15–27 if you want them managed.</p>
</li>
</ul>
</blockquote>
<h2>The problem: your Compose file works, until you try to host it</h2>
<p>You have <code>docker compose up</code> working. Web, Postgres, Redis, a healthcheck, two volumes. Then you pick a host, and it wants a <code>render.yaml</code>, an app spec, a <code>fly.toml</code> or three separate services clicked together by hand.</p>
<p>Most "deploy Docker Compose" guides skip the two questions that matter. Will my file run unchanged? And what will the whole stack cost per month, not just the web box?</p>
<p>This post answers both, from each platform's docs and pricing page, plus a real Compose deploy on Hubfly with a stopwatch.</p>
<h2>The reference stack</h2>
<p>One small web service that waits for a healthy Postgres and a healthy Redis. Both data services have named volumes. A bind mount is included on purpose, to show what a platform does with it.</p>
<pre><code class="language-yaml">services:
  web:
    image: traefik/whoami:v1.10
    ports:
      - "80:80"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    volumes:
      - ./static:/srv:ro        # bind mount, on purpose
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: change-me   # use a secret in real life
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql/data
  cache:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - redisdata:/data
volumes:
  pgdata:
  redisdata:
</code></pre>
<h2>Which platforms take a Compose file?</h2>
<p>Docs and pricing pages are linked in each row and in the price table below. "As-is" means you hand over the file, not a translation of it.</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Compose support</th>
<th>How</th>
<th>What the docs say about keys</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Coolify</strong> (self-hosted)</td>
<td>Yes, native</td>
<td>Git repo or "Raw" mode</td>
<td><code>build:</code>, named volumes, healthchecks supported; bind mounts only inside the resource folder; avoid <code>ports:</code> (<a href="https://coolify.io/docs/applications/build-packs/docker-compose">docs</a>)</td>
</tr>
<tr>
<td><strong>Hubfly</strong></td>
<td>Yes, native</td>
<td>Paste or upload YAML in the Stack Composer</td>
<td>Walkthrough below: <code>depends_on</code> conditions, healthchecks, named volumes, <code>command</code>, <code>ports</code> kept; bind mount dropped</td>
</tr>
<tr>
<td><strong>Railway</strong></td>
<td>Yes, import</td>
<td>Drag the file onto the project canvas</td>
<td>Railway says it doesn't support every Compose config yet; per-key list not published (<a href="https://docs.railway.com/guides/dockerfiles">docs</a>)</td>
</tr>
<tr>
<td><strong>Fly.io</strong></td>
<td>Early</td>
<td><code>[build.compose]</code> in <code>fly.toml</code></td>
<td>Announced in a community post (July 2025), not in the main docs; many features listed as incompatible (<a href="https://community.fly.io/t/docker-compose-compatibility-the-journey-begins/25285">post</a>)</td>
</tr>
<tr>
<td><strong>Render</strong></td>
<td>No</td>
<td>Rewrite as a <code>render.yaml</code> Blueprint</td>
<td>Compose isn't in the <a href="https://render.com/docs/blueprint-spec">Blueprint spec</a>; the <a href="https://feedback.render.com/features/p/support-docker-compose">feature request</a> dates from 2019</td>
</tr>
<tr>
<td><strong>DigitalOcean App Platform</strong></td>
<td>No</td>
<td>App spec, from Git or images</td>
<td>Compose not listed (<a href="https://docs.digitalocean.com/products/app-platform/support/">support page</a>)</td>
</tr>
<tr>
<td><strong>Koyeb</strong></td>
<td>Workaround</td>
<td>Docker-in-Docker in one privileged service</td>
<td>Official <a href="https://koyeb.com/tutorials/deploy-apps-using-docker-compose-on-koyeb">tutorial</a> uses the Privileged toggle; no native import</td>
</tr>
<tr>
<td><strong>Heroku</strong></td>
<td>No</td>
<td>Git or Docker deploys</td>
<td>Compose isn't offered on the <a href="https://www.heroku.com/pricing/">pricing page</a></td>
</tr>
</tbody></table>
<p>Fair warning for every row: "supports Compose" never means every key. Swarm-only <code>deploy:</code> blocks, host networking and bind mounts are where files usually break.</p>
<h2>Hubfly, step by step</h2>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/dc5b9e8d-2eb0-4d3f-ac0a-b3f945fa6b0a.jpg" alt="Hubfly Stack Composer after importing the Compose YAML: 3 services, 2 volumes, $0.02 monthly estimate" style="display:block;margin:0 auto" />

<p><em>The Stack Composer after pasting the YAML: 3 services, 2 volumes, monthly estimate $0.02.</em></p>
<ol>
<li><p><strong>Import.</strong> Create → Compose Stack → YAML tab → paste → Import YAML. It loaded in under a second.</p>
</li>
<li><p><strong>What survives.</strong> Read the generated spec (JSON tab):</p>
</li>
</ol>
<ul>
<li><p><code>depends_on</code> with <code>condition: service_healthy</code> → kept on <code>web</code> for both <code>db</code> and <code>cache</code>.</p>
</li>
<li><p>Both <code>healthcheck</code> blocks → kept, with interval, timeout and retries.</p>
</li>
<li><p>Named volumes → 2 volumes of 1 GB each, mounted at the right paths.</p>
</li>
<li><p><code>command:</code> on Redis → kept.</p>
</li>
<li><p><code>ports: "80:80"</code> → container port 80 exposed.</p>
</li>
<li><p><strong>The bind mount</strong> <code>./static:/srv:ro</code> <strong>→ silently dropped.</strong> No warning in the composer. If your app needs files from the host, bake them into the image or use a volume.</p>
</li>
</ul>
<ol>
<li><p><strong>Deploy.</strong> Click Deploy Stack. "Stack deployment finished" arrives in about <strong>79 seconds</strong> for 3 services and 2 volumes (the deploy page timer, which starts after validation, shows about 1 min 17 s).</p>
</li>
<li><p><strong>Order.</strong> The log is the useful part:</p>
</li>
</ol>
<ul>
<li><p><code>db</code> deployed (10:54:10), then <code>cache</code> (10:54:26).</p>
</li>
<li><p>"Waiting for 'db' before deploying 'web'" → "Service condition satisfied" 2 s later.</p>
</li>
<li><p>Same for <code>cache</code> 3 s later. Then <code>web</code> deployed (10:54:48).</p>
</li>
</ul>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/257b5cf5-f5fc-428b-809f-a67b8b19cd7c.jpg" alt="Compose deployment activity log: web waits for db and cache health conditions before deploying" style="display:block;margin:0 auto" />

<p><em>The deploy activity log: web waits for db and cache to become healthy.</em></p>
<p>One oddity: the composer labels stages in file order. <code>web</code> shows as "Stage 1" even though it deploys last. The real order followed <code>depends_on</code>, so trust the log, not the stage number.</p>
<ol>
<li><strong>Cost on the dashboard.</strong> The project summary projected <strong>$0.03/month</strong>, with the shared tier's auto-sleep on.</li>
</ol>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/364c3cca-3e47-4b69-a5c5-19518902552a.jpg" alt="Project billing panel: projected cost $0.03 per month against a $10 monthly cap" style="display:block;margin:0 auto" />

<p><em>Project Settings → Billing &amp; Monthly Budget: projected \(0.03/mo against a \)10 cap.</em></p>
<p>The composer shows $0.02, the project page $0.03. The gap is most likely the storage class: the volumes were created as Balanced (1.5× the standard rate). 2 GB × $0.00001536 × 1.5 × 730 h = <strong>$0.034</strong>, which rounds to $0.03; at the standard rate it's $0.022. Shared-tier compute shows as free here. Treat that as a dev and staging number, not a production price.</p>
<ol>
<li><strong>Cleanup.</strong> Project Settings → Danger Zone → Delete All Resources, then Delete Project. The whole run takes about 7 minutes.</li>
</ol>
<h2>What the same stack costs, platform by platform</h2>
<p>Assumptions: always on, 730 hours a month, the smallest paid size each platform sells for web, Postgres and Redis, 1 GB of data each, no egress. Usage-based platforms are priced at 0.5 vCPU + 512 MB per service (or the nearest size), which is a ceiling, not an average.</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Web</th>
<th>Postgres</th>
<th>Redis</th>
<th>Monthly total</th>
<th>Managed DB?</th>
<th>Pricing page</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Coolify + Hetzner CX23</strong></td>
<td>one box</td>
<td>on the box</td>
<td>on the box</td>
<td><strong>$6.49 + IPv4</strong> ($11.49 + IPv4 with Coolify Cloud)</td>
<td>No, you run it</td>
<td><a href="https://docs.hetzner.com/general/infrastructure-and-availability/price-adjustment/">Hetzner</a>, <a href="https://coolify.io/pricing">Coolify</a></td>
</tr>
<tr>
<td><strong>Hubfly</strong> (dedicated, always on)</td>
<td>$2.19</td>
<td>$2.19 + volume</td>
<td>$2.19 + volume</td>
<td><strong>≈ $6.67</strong></td>
<td>No, containers + volumes</td>
<td><a href="https://www.hubfly.space/pricing">Hubfly pricing</a>, math below</td>
</tr>
<tr>
<td><strong>Fly.io</strong>, self-run DBs</td>
<td>$3.69</td>
<td>\(3.69 + \)0.15 vol</td>
<td>\(3.69 + \)0.15 vol</td>
<td><strong>$11.37</strong> (+$2 for a dedicated IPv4)</td>
<td>No</td>
<td><a href="https://docs.fly.io/about/pricing">Fly pricing</a></td>
</tr>
<tr>
<td><strong>Heroku</strong></td>
<td>$7 Basic</td>
<td>$5 Mini</td>
<td>$3 Mini</td>
<td><strong>$15</strong></td>
<td>Yes</td>
<td><a href="https://www.heroku.com/pricing">Dynos</a>, <a href="https://help.heroku.com/1CDF2VHY/what-are-your-cheapest-heroku-postgres-and-heroku-data-for-redis-plans">Postgres + Key-Value</a></td>
</tr>
<tr>
<td><strong>Render</strong></td>
<td>$7</td>
<td>$6</td>
<td>$10 Key Value</td>
<td><strong>$23</strong></td>
<td>Yes</td>
<td><a href="https://render.com/pricing">Render pricing</a></td>
</tr>
<tr>
<td><strong>DigitalOcean App Platform</strong></td>
<td>$5</td>
<td>$7 Dev DB</td>
<td>$15 Valkey</td>
<td><strong>$27</strong> ($35.15 with a full managed Postgres)</td>
<td>Yes</td>
<td><a href="https://docs.digitalocean.com/products/app-platform/details/pricing/">App Platform</a>, <a href="https://docs.digitalocean.com/products/databases/valkey/details/pricing/">Valkey</a>, <a href="https://www.digitalocean.com/pricing/managed-databases">Databases</a></td>
</tr>
<tr>
<td><strong>Koyeb</strong></td>
<td>$5.36 micro</td>
<td>$30.26 (Small $29.76 + 1 GB at $0.50)</td>
<td>$1.61 eco-nano, self-run</td>
<td><strong>$37.23</strong> (a plan fee may apply; check the pricing page)</td>
<td>Postgres yes</td>
<td><a href="https://www.koyeb.com/docs/reference/instances">Instances</a>, <a href="https://www.koyeb.com/pricing">Postgres pricing</a></td>
</tr>
<tr>
<td><strong>Railway</strong></td>
<td>$15</td>
<td>\(15 + \)0.15 vol</td>
<td>\(15 + \)0.15 vol</td>
<td><strong>up to ≈ $45.30</strong></td>
<td>No, services you run</td>
<td><a href="https://docs.railway.com/reference/pricing/plans">Plans</a></td>
</tr>
<tr>
<td><strong>Fly.io</strong>, managed</td>
<td>$3.69</td>
<td>$38.28 MPG Basic</td>
<td>$10 Upstash fixed</td>
<td><strong>$51.97</strong></td>
<td>Yes</td>
<td><a href="https://docs.fly.io/about/pricing">Fly pricing</a>, <a href="https://fly.io/docs/upstash/redis/">Upstash on Fly</a></td>
</tr>
</tbody></table>
<p>How to read Railway's row: it bills real usage, per second. $45.30 assumes all three services use their full 0.5 vCPU and 512 MB around the clock ($20/vCPU-month × 0.5 + \(10/GB-month × 0.5 = \)15 each). An idle side project uses far less, and the $5 Hobby fee covers the first $5. An idle Railway bill depends on your app, so it isn't quoted here.</p>
<h3>The Hubfly math, so you can check it</h3>
<p>Published rates (<a href="https://www.hubfly.space/pricing">pricing page</a>): CPU \(0.004 per vCPU-hour, RAM \)0.002 per GB-hour, storage $0.00001536 per GB-hour, 730 hours a month.</p>
<ul>
<li><p>One service at 0.5 vCPU / 512 MB: 0.5 × 0.004 × 730 = <strong>$1.46</strong> CPU, plus 0.5 × 0.002 × 730 = <strong>$0.73</strong> RAM → <strong>$2.19</strong>.</p>
</li>
<li><p>Three services: 3 × $2.19 = <strong>$6.57</strong>.</p>
</li>
<li><p>Two 1 GB Balanced volumes: 2 × 0.00001536 × 1.5 × 730 = <strong>$0.034</strong>.</p>
</li>
<li><p>Container disk, 2 GB each (the dedicated tier's minimum in the dashboard): 6 × 0.00001536 × 730 = <strong>$0.067</strong>.</p>
</li>
<li><p><strong>Total ≈ $6.67/month.</strong></p>
</li>
</ul>
<p>This row is computed from published dedicated-tier rates.</p>
<h2>Where the competitors win (and they do)</h2>
<ul>
<li><p><strong>Managed databases.</strong> Heroku, Render and DigitalOcean run Postgres and Redis for you. If you'd rather never think about Postgres upgrades or restores, the $15–27 tier is worth it. Render's $6 Postgres includes point-in-time recovery on the Hobby workspace, per its <a href="https://render.com/pricing">pricing page</a>. On Hubfly, Coolify and self-run Fly, Postgres is a container you own, and backups are your job.</p>
</li>
<li><p><strong>Coolify runs Compose closest to raw</strong>, including <code>build:</code> from your repo, and one flat-priced server. You also patch that server yourself.</p>
</li>
<li><p><strong>Railway's import is drag-and-drop:</strong> drop the file on the canvas and review the staged services (<a href="https://docs.railway.com/guides/dockerfiles">docs</a>).</p>
</li>
<li><p><strong>Fly's 256 MB Machine is $2.19/month</strong>, and Fly lists 17 regions in its <a href="https://docs.fly.io/about/pricing">pricing selector</a>, against Hubfly's 3. Koyeb's eco-nano ($1.61) is the cheapest always-on container in this table.</p>
</li>
<li><p><strong>Heroku's cheapest managed Postgres + Redis pair costs $8</strong> together, the lowest managed pair in this table.</p>
</li>
</ul>
<p>Where Hubfly fits: your Compose file ran unchanged, <code>depends_on</code> health conditions were respected, and a 3-service always-on stack is about $6.67 by the published rates. The trade-off is that you manage the database containers. Hubfly doesn't document database backups, so schedule your own <code>pg_dump</code> to off-site storage.</p>
<h2>What can go wrong</h2>
<ol>
<li><p><strong>Your bind mounts disappear.</strong> Hubfly dropped <code>./static:/srv:ro</code> without a warning, and Coolify restricts them to the resource folder. Fix: <code>COPY</code> the files into the image, or use a named volume.</p>
</li>
<li><p><strong>The app starts before the database is ready.</strong> <code>depends_on</code> without a condition only waits for the container to start. Fix: add a <code>healthcheck</code> and <code>condition: service_healthy</code>, as above. For migrations, gate the app on a one-shot job (see the <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=deploy-docker-compose-as-is-8-platforms-prices">migration job guide</a>).</p>
</li>
<li><p><strong>502 after a "successful" deploy.</strong> Your app is listening on 127.0.0.1 inside the container. Fix: bind to <code>0.0.0.0</code> (<a href="https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&amp;utm_campaign=deploy-docker-compose-as-is-8-platforms-prices">1-line fix</a>).</p>
</li>
</ol>
<p>Bonus trap: a hard-coded <code>POSTGRES_PASSWORD</code> in Compose is fine on a laptop and bad in a repo. Use the platform's secret or encrypted env vars.</p>
<h2>Try it yourself</h2>
<ol>
<li><p>Create a Cell project with a $10 budget cap (Advanced Options at project creation).</p>
</li>
<li><p>Create → Compose Stack → YAML → paste the file above (change the password) → Import YAML.</p>
</li>
<li><p>Check the JSON tab to see what survived. Deploy Stack and watch the activity log.</p>
</li>
<li><p>Delete All Resources, then Delete Project when you're done.</p>
</li>
</ol>
<p>For competitor prices, open the linked pricing pages: prices change.</p>
<h2>Takeaway</h2>
<p>If you want your Compose file to run with no rewrite, your real shortlist is Coolify (your own server) or a platform with native import like Hubfly or Railway. If you want managed Postgres and Redis and don't mind translating your file, Heroku ($15) and Render ($23) are the honest picks.</p>
<p><strong>Next:</strong> <a href="https://blog.hubfly.space/canary-release-weighted-load-balancer?utm_source=hashnode&amp;utm_campaign=deploy-docker-compose-as-is-8-platforms-prices">Canary releases with a weighted load balancer</a>, once your stack is live and you want to ship v2 safely.</p>
<p><strong>Price your own stack:</strong> open the <a href="https://www.hubfly.space/pricing">Hubfly pricing calculator</a>, set 3 dedicated services at 0.5 vCPU / 512 MB, and compare with the table above.</p>
]]></content:encoded></item><item><title><![CDATA[Isolated SaaS tenants on Hubfly: 1 wallet and 1 key per customer in 4 API calls]]></title><description><![CDATA[Originally published on the Hubfly blog.

TL;DR

What you get: a pattern for giving each customer their own wallet, projects and machine key on Hubfly, using sub-accounts. Three control-plane calls pe]]></description><link>https://octave.hashnode.dev/isolated-saas-tenants-on-hubfly-1-wallet-and-1-key-per-customer-in-4-api-calls</link><guid isPermaLink="true">https://octave.hashnode.dev/isolated-saas-tenants-on-hubfly-1-wallet-and-1-key-per-customer-in-4-api-calls</guid><category><![CDATA[SaaS]]></category><category><![CDATA[api]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Sun, 18 Oct 2026 09:01:52 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the</em> <a href="https://blog.hubfly.space/multi-tenant-saas-isolated-tenants-hubfly-subaccounts?utm_source=hashnode&amp;utm_campaign=multi-tenant-saas-isolated-tenants-hubfly-subaccounts"><em>Hubfly blog</em></a><em>.</em></p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><p><strong>What you get:</strong> a pattern for giving each customer their own wallet, projects and machine key on Hubfly, using sub-accounts. Three control-plane calls per customer, then the normal API.</p>
</li>
<li><p><strong>Who it's for:</strong> SaaS teams, agencies and AI-agent builders who run infrastructure <em>for</em> their customers.</p>
</li>
<li><p><strong>Time:</strong> about 10 minutes to read. Creating, suspending and revoking a tenant in the dashboard takes about 2 minutes.</p>
</li>
</ul>
</blockquote>
<h2>The problem: one customer's bill is everyone's bill</h2>
<p>You run a SaaS. Each customer gets a database, a worker or a preview app. On most clouds, all of that lands in one account and one bill.</p>
<p>Then customer 1042's import job loops all weekend. You find out from your card statement. You also can't tell which customer owes what, and one leaked key reaches every tenant.</p>
<p>The textbook answer is the "silo" model: a separate account per customer. On AWS this usually means AWS Organizations and an account-vending process. Most guides stop at the diagram. This post shows the actual calls on Hubfly, and the limits.</p>
<h2>What Hubfly sub-accounts are</h2>
<p>A sub-account is a tenant, not a team member. It has no password, no login and no invitations. Your software talks to it with a machine key (<a href="https://www.hubfly.space/docs/subaccounts">docs</a>).</p>
<p>Each tenant gets:</p>
<ul>
<li><p><strong>Its own wallet</strong>, with paid and promotional balances, in integer micro-USD (<code>1000000</code> = $1.00).</p>
</li>
<li><p><strong>Its own projects</strong>, which are ordinary Hubfly projects: containers, Compose, volumes, domains, firewall, logs.</p>
</li>
<li><p><strong>Its own keys.</strong> An <code>hfsk_</code> key is locked to one tenant. A parent <code>hfpk_</code> key can manage all your tenants and target one with the <code>X-HubFly-Subaccount</code> header.</p>
</li>
<li><p><strong>An</strong> <code>externalRef</code>, unique inside your parent, so you can map <code>customer_1042</code> in your database to <code>sub_…</code> in Hubfly.</p>
</li>
</ul>
<p>The docs list the use cases plainly: SaaS customer infrastructure, AI agents, managed hosting, preview environments and internal developer portals.</p>
<pre><code class="language-text">Your app (parent hfpk_ key)
 ├── sub_A  wallet $25 ── project ── containers, volumes, domains
 ├── sub_B  wallet $10 ── project ── ...
 └── sub_C  suspended  (keys disabled)
</code></pre>
<h2>Walkthrough: the dashboard flow</h2>
<p>Open <strong>Profile → Sub-accounts</strong>.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/b9c04f72-f411-4ac1-a8fb-2edcabd9044c.jpg" alt="The Create sub-account dialog with Account name and External reference fields" style="display:block;margin:0 auto" />

<ol>
<li><p><strong>Create.</strong> Click <strong>New sub-account</strong>, type a name such as <code>acme-corp</code> and an external reference such as <code>tenant_001</code>, and click <strong>Create sub-account</strong>. It shows as <strong>Active</strong> with a <code>sub_…</code> ID within seconds.</p>
</li>
<li><p><strong>Inspect.</strong> <strong>Manage</strong> opens a panel with the wallet balance ($0.00), active keys, status, a <strong>Fund wallet</strong> box ("Move paid balance from the parent account"), <strong>Machine credentials</strong> and <strong>Suspend account</strong>.</p>
</li>
</ol>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/63d1d2da-b253-455d-b56a-73cc5466b37e.jpg" alt="Tenant panel: wallet $0.00, 0 active keys, status Active" style="display:block;margin:0 auto" />

<ol>
<li><strong>Key (the surprise).</strong> <strong>New key</strong> has no form. The dashboard immediately creates a key called <strong>Master key</strong> and shows the <code>hfsk_</code> secret once, with "HubFly stores only a hash of this key and cannot recover it later." If you don't need it, close the dialog and click <strong>Revoke</strong>. The banner says "API key revoked." and active keys go back to 0. Note that <strong>Revoke</strong> also has no confirmation step.</li>
</ol>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/7ff9c508-fa9b-4129-96f2-b589b6543515.jpg" alt="Banner &quot;API key revoked.&quot; with active keys back to 0" style="display:block;margin:0 auto" />

<ol>
<li><strong>Suspend.</strong> <strong>Suspend account</strong> works in one click, again with no confirmation. Status changes to <strong>Suspended</strong>, <strong>New key</strong> greys out, and the button becomes <strong>Resume account</strong>.</li>
</ol>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/44eccc1e-1d6e-4bf2-b152-0b5d66b8c701.jpg" alt="Tenant status Suspended with a Resume account button" style="display:block;margin:0 auto" />

<p>Everything above costs $0.00. Funding the wallet and closing a tenant happen through the API below; the dashboard has no Close button, so a tenant you stop using stays suspended until you close it by API.</p>
<h2>The production flow: 4 calls to onboard, 1 to close</h2>
<p>The dashboard is fine for a look around. For real customers, provision from your backend with a parent platform key. These come from the <a href="https://www.hubfly.space/docs/api/subaccounts">Sub-account API guide</a>; all paths are under <code>https://api.hubfly.space/api/v1</code>.</p>
<p><strong>1. Create the tenant.</strong> Use an idempotency key, so a retry returns the same tenant instead of a duplicate.</p>
<pre><code class="language-bash">curl -X POST https://api.hubfly.space/api/v1/subaccounts/create \
  -H "Authorization: Bearer $HUBFLY_PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tenant-customer-1042-v1" \
  -d '{"name":"Customer 1042","externalRef":"customer_1042"}'
</code></pre>
<p><strong>2. Fund its wallet.</strong> This moves paid balance from your parent wallet. Promotional credit can't be transferred.</p>
<pre><code class="language-bash">curl -X POST https://api.hubfly.space/api/v1/subaccounts/$SUBACCOUNT_ID/wallet/transfers \
  -H "Authorization: Bearer $HUBFLY_PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: funding-customer-1042-2026-10" \
  -d '{"amountMicroUsd":"25000000","idempotencyKey":"funding-customer-1042-2026-10"}'
</code></pre>
<p><strong>3. Mint a tenant key</strong> and write it straight to your secret manager. It's shown once.</p>
<pre><code class="language-bash">curl -X POST https://api.hubfly.space/api/v1/subaccounts/$SUBACCOUNT_ID/api-keys/create \
  -H "Authorization: Bearer $HUBFLY_PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"production workload"}'
</code></pre>
<p><strong>4. Use the normal endpoints.</strong> There are no special tenant URLs. With the tenant key, <code>GET /projects</code> lists only that tenant's projects. With your platform key, add <code>-H "X-HubFly-Subaccount: $SUBACCOUNT_ID"</code>. The TypeScript and Go SDKs and the CLI (<code>--subaccount sub_…</code> or <code>HUBFLY_SUBACCOUNT</code>) support the same pattern.</p>
<p><strong>5. Close when the customer leaves.</strong> Stop and remove the tenant's projects, settle any negative balance, then call <code>POST /subaccounts/$SUBACCOUNT_ID/close</code>. Eligible paid balance returns to your parent wallet; promotional balance expires; keys are revoked.</p>
<p>Isolation rules: an <code>hfsk_</code> key sent with a different tenant header gets <code>403 SUBACCOUNT_SCOPE</code>, and a platform key can't target a tenant outside its parent.</p>
<h2>Limits and pricing (honest version)</h2>
<p><strong>Limits:</strong></p>
<ul>
<li><p>25 active sub-accounts per parent and 10 active keys per parent or tenant, by default.</p>
</li>
<li><p>One level deep: a tenant can't create tenants. You can't move a tenant to another parent.</p>
</li>
<li><p>No GPU, no GitHub OAuth repo discovery, no team members or invitations, no payment methods inside a tenant.</p>
</li>
<li><p>No automatic top-up for tenant wallets in the first release.</p>
</li>
<li><p><strong>Suspending doesn't stop running resources.</strong> The docs say to stop billable resources before you suspend.</p>
</li>
<li><p>Closing needs no active projects and no negative balance. Force close isn't available yet.</p>
</li>
</ul>
<p><strong>Pricing:</strong> sub-accounts have no listed fee. A tenant pays normal Hubfly rates from its own wallet: \(0.004 per vCPU-hour, \)0.002 per GB-hour of RAM, $0.00001536 per GB-hour of Standard storage.</p>
<table>
<thead>
<tr>
<th>Per tenant, always on (730 h)</th>
<th>Math</th>
<th>Monthly</th>
</tr>
</thead>
<tbody><tr>
<td>1 vCPU, 0.5 GB RAM, 5 GB disk</td>
<td>2.92 + 0.73 + 0.06</td>
<td><strong>$3.71</strong></td>
</tr>
<tr>
<td>0.5 vCPU, 0.5 GB RAM, 5 GB disk</td>
<td>1.46 + 0.73 + 0.06</td>
<td><strong>$2.25</strong></td>
</tr>
<tr>
<td>25 tenants on the first size</td>
<td>25 × $3.706</td>
<td><strong>$92.65</strong></td>
</tr>
</tbody></table>
<p>Whether each tenant gets its own 360 free hours isn't documented, so don't price your plans on it. Per the <a href="https://www.hubfly.space/pricing">pricing page</a>, sleeping containers pause compute billing; volumes are still billed.</p>
<h2>What can go wrong</h2>
<ol>
<li><p><strong>You mint keys by accident.</strong> One click on <strong>New key</strong> creates a key named Master key. Create keys from your backend, name them per workload, and revoke old ones after rotation.</p>
</li>
<li><p><strong>You suspend a tenant and it keeps billing.</strong> Suspension disables credentials and changes, not running containers. Stop the tenant's workloads first, then suspend.</p>
</li>
<li><p><strong>A customer passes someone else's tenant ID.</strong> Never accept a <code>subaccountId</code> from the client. Resolve it from your own customer record, and alert on <code>403 SUBACCOUNT_SCOPE</code>.</p>
</li>
<li><p><code>409 API_KEY_LIMIT_REACHED</code><strong>.</strong> A tenant holds 10 active keys by default. Revoke unused keys before creating another.</p>
</li>
</ol>
<p>Bonus: an ambiguous funding failure. Don't retry with a new idempotency key until you've read the wallet; you could fund twice.</p>
<h2>Who this is NOT for</h2>
<ul>
<li><p><strong>Your own team.</strong> For colleagues, use an organization with roles. Sub-accounts have no logins.</p>
</li>
<li><p><strong>GPU workloads per customer.</strong> Not supported in tenants today.</p>
</li>
<li><p><strong>More than 25 customers, today, with no plan.</strong> That's the default cap. Talk to us first if you need more.</p>
</li>
<li><p><strong>Customers who must hold their own bill.</strong> Tenants have no payment methods; you fund them from your paid balance.</p>
</li>
</ul>
<h2>Takeaway</h2>
<p>If you sell software that runs on infrastructure, sub-accounts give you the silo model without account vending: one wallet, one project namespace and one revocable key per customer. Provision from your backend with idempotency keys, keep keys out of the dashboard, and stop workloads before you suspend.</p>
<p>Read next: <a href="https://blog.hubfly.space/coding-agent-deploy-safely-hubfly?utm_source=hashnode&amp;utm_campaign=multi-tenant-saas-isolated-tenants-hubfly-subaccounts">Let your coding agent deploy to Hubfly safely</a> (give each agent its own tenant), <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=multi-tenant-saas-isolated-tenants-hubfly-subaccounts">Gate your app on a Postgres migration job</a> and <a href="https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&amp;utm_campaign=multi-tenant-saas-isolated-tenants-hubfly-subaccounts">502 Bad Gateway in Docker? Bind to 0.0.0.0</a>.</p>
<p><strong>Building a platform on Hubfly?</strong> <a href="https://www.hubfly.space/">Talk to sales</a> about tenant limits, or <a href="https://dashboard.hubfly.space/">start free with 360 hours included</a> and create your first sub-account in Profile → Sub-accounts.</p>
]]></content:encoded></item><item><title><![CDATA[Canary releases with a weighted load balancer: 10% to v2, proven with 200 requests]]></title><description><![CDATA[TL;DR

What you get: a canary release that sends exactly 1 in 10 requests to a new version, a 7-line script that proves the split, and a rollback that takes effect in under a second.

Who it's for: an]]></description><link>https://octave.hashnode.dev/canary-releases-with-a-weighted-load-balancer-10-to-v2-proven-with-200-requests</link><guid isPermaLink="true">https://octave.hashnode.dev/canary-releases-with-a-weighted-load-balancer-10-to-v2-proven-with-200-requests</guid><category><![CDATA[Devops]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Load Balancing]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Sun, 18 Oct 2026 08:59:38 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><p><strong>What you get:</strong> a canary release that sends exactly 1 in 10 requests to a new version, a 7-line script that proves the split, and a rollback that takes effect in under a second.</p>
</li>
<li><p><strong>Who it's for:</strong> anyone running plain containers (no Kubernetes, no service mesh) who wants to ship v2 to a slice of users first.</p>
</li>
<li><p><strong>Time:</strong> about 15 minutes.</p>
</li>
</ul>
</blockquote>
<p>You've got v2 ready. The tests pass. Your stomach still says "not for everyone at once."</p>
<p>A canary release fixes that: v2 gets a small slice of real traffic, you watch it, then you turn the dial up or pull it out. Setting a 90/10 split takes a minute. Checking that you actually got one is the part people skip, and a 90/10 setting can quietly give you something else.</p>
<h2>How a weighted canary works</h2>
<p>A load balancer sits in front of two backends. Each backend gets a weight. With weights 9 and 1, a weighted round robin sends 9 requests to v1, then 1 to v2, and repeats.</p>
<pre><code class="language-text">           ┌──────────────────────┐
Internet → │  load balancer (WRR) │ ──9──→ app-v1 (stable)
           │  canary-router       │ ──1──→ app-v2 (canary)
           └──────────────────────┘
</code></pre>
<p>Three things decide whether this works in practice:</p>
<ol>
<li><p><strong>The strategy must actually read the weights.</strong> Some strategies ignore them.</p>
</li>
<li><p><strong>Sticky strategies change who sees v2.</strong> IP hashing pins each client to one backend, so a 10% canary means "10% of clients", and you may never be one of them.</p>
</li>
<li><p><strong>Rollback must be one action.</strong> If pulling v2 out takes a redeploy, it isn't a canary, it's a hope.</p>
</li>
</ol>
<p>Build it on Hubfly, then check all three.</p>
<h2>Step 1: deploy v1 and v2 side by side</h2>
<p>Here, both versions are <a href="https://hub.docker.com/r/hashicorp/http-echo"><code>hashicorp/http-echo</code></a>, a tiny server that replies with a fixed string. That makes counting trivial: every response says <code>v1</code> or <code>v2</code>. In real life, these are your two image tags.</p>
<pre><code class="language-yaml">services:
  app-v1:
    image: hashicorp/http-echo:1.0
    command: ["-listen=:5678", "-text=v1"]
  app-v2:
    image: hashicorp/http-echo:1.0
    command: ["-listen=:5678", "-text=v2"]
</code></pre>
<p>In the Hubfly dashboard: open your project, click <strong>Create → Compose Stack</strong>, open the <strong>YAML</strong> tab, paste the file, click <strong>Import YAML</strong>, then <strong>Deploy Stack</strong>. Neither service publishes a port. The load balancer will be the only way in.</p>
<p>The stack is up in about 33 seconds.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/4149ce9b-2138-4323-b66d-db6fa491ccbe.png" alt="Compose deployment complete: app-v1 and app-v2 deployed in 33 s" style="display:block;margin:0 auto" />

<h2>Step 2: create the load balancer</h2>
<p>Click <strong>Create → Create Load Balancer</strong>. The editor has three columns: ingress domains on the left, the balancer in the middle, backend targets on the right.</p>
<ol>
<li><p><strong>Balancer name:</strong> <code>canary-router</code>.</p>
</li>
<li><p><strong>Backend targets → Add Target Container:</strong> pick <code>app-v1</code>, set <strong>Port</strong> to <code>5678</code>, <strong>Weight</strong> to <code>9</code>, click <strong>Add Target</strong>. Repeat for <code>app-v2</code> with weight <code>1</code>.</p>
</li>
<li><p><strong>Ingress domains → Add Domain → Hubfly Free → Attach Free Domain.</strong> You get an HTTPS subdomain like <code>canary-router-xx-xxx.eu1.hubfly.app</code>.</p>
</li>
<li><p><strong>Strategy:</strong> choose <strong>Weighted Round Robin</strong>. Not "Round Robin". This is the step the rest of this post is about.</p>
</li>
<li><p>Click <strong>Deploy Load Balancer</strong>.</p>
</li>
</ol>
<p>Watch the port: the target form defaults to <code>3000</code>. This app listens on <code>5678</code>, so leaving the default points the balancer at a closed port.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/06d30de2-ca50-40e0-bc5d-0d57bb913469.png" alt="Strategy dropdown: Round Robin, Least Connections, IP Hash, Weighted Round Robin, Weighted Least Connections, Weighted IP Hash, Random, Weighted Random" style="display:block;margin:0 auto" />

<p>Hubfly's <a href="https://www.hubfly.space/docs/load-balancers/">load balancer docs</a> describe three strategies (<code>round_robin</code>, <code>least_conn</code>, <code>ip_hash</code>) and a canary recipe with <code>weight: 9</code> and <code>weight: 1</code>. The dashboard offers eight. Only the weighted strategies apply your weights.</p>
<h2>Step 3: prove the split (don't trust the percentages)</h2>
<p>Open your load balancer's domain in a browser tab. It shows <code>v1</code> or <code>v2</code>. Then open the browser console (F12 → Console) on that tab and paste:</p>
<pre><code class="language-javascript">const counts = {};
for (let i = 0; i &lt; 200; i++) {
  const r = await fetch('/?n=' + i + '&amp;t=' + Date.now(), { cache: 'no-store' });
  const body = (await r.text()).trim();
  counts[r.status + ' ' + body] = (counts[r.status + ' ' + body] || 0) + 1;
}
console.table(counts);
</code></pre>
<p>It sends 200 sequential requests from the same origin, skips the cache, and counts each status-plus-body pair. A run takes about 80 seconds (median 402 ms per request).</p>
<p>Here is what you get with the same two containers and the same 9/1 weights, changing only the strategy:</p>
<table>
<thead>
<tr>
<th>Strategy</th>
<th>Targets / weights</th>
<th>Requests</th>
<th>v1</th>
<th>v2</th>
<th>v2 share</th>
</tr>
</thead>
<tbody><tr>
<td>Round Robin</td>
<td>app-v1 w9, app-v2 w1 (dashboard showed 90% / 10%)</td>
<td>200</td>
<td>78</td>
<td>122</td>
<td><strong>61%</strong></td>
</tr>
<tr>
<td>Round Robin, 2nd run</td>
<td>same</td>
<td>200</td>
<td>100</td>
<td>100</td>
<td><strong>50%</strong></td>
</tr>
<tr>
<td>Round Robin</td>
<td>3 × v1 + 1 × v2, all weight 1</td>
<td>200</td>
<td>150</td>
<td>50</td>
<td>25%</td>
</tr>
<tr>
<td><strong>Weighted Round Robin</strong></td>
<td>app-v1 w9, app-v2 w1</td>
<td>200</td>
<td>180</td>
<td>20</td>
<td><strong>10%</strong></td>
</tr>
<tr>
<td>Weighted IP Hash</td>
<td>app-v1 w9, app-v2 w1, one client</td>
<td>40</td>
<td>40</td>
<td>0</td>
<td>0%</td>
</tr>
</tbody></table>
<p>Every response was a clean <code>v1</code> or <code>v2</code> reply, with no errors.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/6d2a19d9-7b7b-47cd-abce-a40a2248aa12.png" alt="Split results for each load-balancer strategy" style="display:block;margin:0 auto" />

<p>What the table says:</p>
<ul>
<li><p><strong>Round Robin ignores your weights.</strong> The target cards still show "90%" and "10%", which is what makes it dangerous. A repeat run settles into strict alternation: v1, v2, v1, v2. Your "10% canary" is a coin flip. (A first run can start with 15 v1 responses, then a long v2 streak, then the same alternation; that run gave v2 61%.)</p>
</li>
<li><p><strong>Weighted Round Robin is exact.</strong> v2 answered request 1, 11, 21 and so on. Precisely 20 of 200. No randomness, so you don't need thousands of requests to trust it.</p>
</li>
<li><p><strong>Target count also works.</strong> Three v1 containers and one v2 under plain Round Robin gave exactly 25%. That's the fallback if you're on a balancer without weights, but it costs you extra containers.</p>
</li>
<li><p><strong>IP hash pins you.</strong> With Weighted IP Hash, all 40 requests from a single browser go to v1. That's what you want for sessions. It's terrible for checking your canary from your own laptop.</p>
</li>
</ul>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/9081c72a-4167-4f7d-a959-d5c11a0bb6be.png" alt="Load balancer set to Weighted Round Robin, app-v1 weight 9 (90%), app-v2 weight 1 (10%)" style="display:block;margin:0 auto" />

<h2>Step 4: watch v2, then promote or roll back</h2>
<p>While v2 takes its 10%, watch two numbers per version: error rate and latency. On Hubfly, each container has its own <strong>Logs</strong> and <strong>Metrics</strong> tabs, so you can check <code>app-v1</code> and <code>app-v2</code> separately. One <a href="https://oneuptime.com/blog/post/2026-01-24-traefik-canary-deployments/view">Traefik canary guide</a> rolls back when the canary's 5xx rate passes 5%, and alerts when p95 latency stays above 2 seconds for 5 minutes. Pick numbers that match your app.</p>
<p><strong>Promote:</strong> raise <code>app-v2</code>'s weight in steps (for example 1 → 3 → 9 against v1's 9), checking each step with the script. When v2 carries everything, remove <code>app-v1</code> from the targets. The balancer details panel edits weights live, and every step uses the same control. The balancer also health-checks targets with lightweight requests to paths like <code>/health</code> and drops a failing one from rotation, as described in the <a href="https://www.hubfly.space/docs/load-balancers/">docs</a>. Give v2 a real health endpoint so that check means something.</p>
<p><strong>Roll back:</strong> open the balancer, click the trash icon on the <code>app-v2</code> target. With a request loop running, the last v2 response comes back about 0.66 s after the click, and none of the 108 requests in that loop fail.</p>
<h2>What can go wrong</h2>
<p><strong>1. "I set 90/10 and v2 gets half the traffic."</strong> Your strategy is plain Round Robin. It ignores weights, even though the UI shows percentages. Pick the Weighted version of whichever strategy you use, then rerun the script.</p>
<p><strong>2. "I can't reproduce the bug users report on v2."</strong> You're on an IP-hash strategy, and your IP hashes to v1. Weights then decide <em>which clients</em> land on v2, not which requests. Test v2 directly before adding it to the balancer, or temporarily use Weighted Round Robin while you test.</p>
<p><strong>3. "v2 never answers after I add it."</strong> Check the target port. The form defaults to <code>3000</code>. If your container listens elsewhere (here: <code>5678</code>), the balancer is knocking on a closed door. Also check that v2 is running before you add it.</p>
<p>Two smaller gotchas from the dashboard: the <strong>+</strong> button next to a weight steps by 5 (1 → 6 → 11), and scrolling the mouse wheel over a weight slider changes it. Re-read the weights before you close the panel.</p>
<h2>The cost</h2>
<p>Four shared-tier containers plus the balancer project to <strong>$0.00/month</strong> in the dashboard. The shared tier counts against the free 360 compute hours a month.</p>
<p>For a production-sized canary, say both versions as dedicated containers with 1 vCPU, 0.5 GB RAM and 5 GB disk, each one costs about $3.71/month always on:</p>
<ul>
<li><p>CPU: 1 × \(0.004 × 730 h = \)2.92</p>
</li>
<li><p>RAM: 0.5 × \(0.002 × 730 h = \)0.73</p>
</li>
<li><p>Disk: 5 × $0.00001536 × 730 h ≈ $0.06</p>
</li>
</ul>
<p>The canary only exists during the rollout. Running v2 next to v1 for a 24-hour canary adds about $0.12.</p>
<h2>Takeaway</h2>
<p>Setting a weight is not the same as getting a split. Pick a weighted strategy, then count. Two hundred requests and one table tell you more than the dashboard's percentages do.</p>
<p>Read next:</p>
<ul>
<li><p><a href="https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&amp;utm_campaign=canary-release-weighted-load-balancer">502 Bad Gateway in Docker? Bind to 0.0.0.0, not 127.0.0.1</a>: if your new target returns 502s, start here.</p>
</li>
<li><p><a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=canary-release-weighted-load-balancer">Gate your app on a Postgres migration job</a>: schema changes are the main reason a rollback isn't safe. Make them backward-compatible before the canary.</p>
</li>
<li><p><a href="https://blog.hubfly.space/coding-agent-deploy-safely-hubfly?utm_source=hashnode&amp;utm_campaign=canary-release-weighted-load-balancer">Let your coding agent deploy to Hubfly, not delete your data</a>.</p>
</li>
</ul>
<p><strong>Finished with the canary?</strong> Delete the load balancer separately. Danger Zone → Delete All Resources removes the containers but leaves the balancer behind.</p>
<h2>Deploy this</h2>
<p>Paste the Compose file above into a new Hubfly project, add a load balancer with <strong>Weighted Round Robin</strong> and weights 9 and 1, and run the script. The free tier needs no card, and this setup projects to $0.00/month. <a href="https://dashboard.hubfly.space">Start on Hubfly</a>.</p>
]]></content:encoded></item><item><title><![CDATA[502 Bad Gateway in Docker? Bind to 0.0.0.0, not 127.0.0.1 (1-line fix)]]></title><description><![CDATA[Originally published on the Hubfly blog.

TL;DR

What you get: a 30-second check that tells you if a 502 is a bind-address bug, the one-line fix for 9 common servers, and 3 traps to avoid.

Who it's f]]></description><link>https://octave.hashnode.dev/502-bad-gateway-in-docker-bind-to-0-0-0-0-not-127-0-0-1-1-line-fix</link><guid isPermaLink="true">https://octave.hashnode.dev/502-bad-gateway-in-docker-bind-to-0-0-0-0-not-127-0-0-1-1-line-fix</guid><category><![CDATA[Docker]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Fri, 16 Oct 2026 22:01:58 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the</em> <a href="https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&amp;utm_campaign=docker-502-bad-gateway-bind-0-0-0-0"><em>Hubfly blog</em></a><em>.</em></p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><p><strong>What you get:</strong> a 30-second check that tells you if a 502 is a bind-address bug, the one-line fix for 9 common servers, and 3 traps to avoid.</p>
</li>
<li><p><strong>Who it's for:</strong> anyone whose container logs say "listening" while the browser says 502.</p>
</li>
<li><p><strong>Time:</strong> 5 minutes to diagnose, 1 line to fix.</p>
</li>
<li></li>
</ul>
</blockquote>
<p>Your container is running. The logs say the server started. The dashboard says the port is open. And every request through the proxy gets <strong>502 Bad Gateway</strong>.</p>
<p>Often, nothing is "down". Your app is listening on <code>127.0.0.1</code>, and inside a container that address means "this container only". The proxy knocks on the container's network interface, nobody answers, and you get a 502.</p>
<p>Here's how to prove it in 30 seconds, fix it in one line, and avoid three easy traps.</p>
<h2>Why 127.0.0.1 breaks inside a container</h2>
<p>Every container has its own network namespace. It has its own loopback (<code>lo</code>, 127.0.0.1) and its own interface (<code>eth0</code>, a private IP).</p>
<pre><code class="language-text">Internet ──HTTPS──► reverse proxy ──► 10.240.82.2:8000 (eth0) ──✗──  app on 127.0.0.1:8000
                                                                     (only lo can reach it)
</code></pre>
<p>A server bound to <code>127.0.0.1:8000</code> accepts connections that arrive on <code>lo</code> only. The proxy's request arrives on <code>eth0</code>, so the kernel refuses it. The proxy turns "connection refused" into a 502.</p>
<p>Binding to <code>0.0.0.0</code> means "every interface in this container", which includes <code>eth0</code>. It does <strong>not</strong> open anything on the host or the internet by itself: the container is still only reachable through the routes you set up.</p>
<h2>Reproduce it: one broken container</h2>
<p>Deploy this on Hubfly with the Compose import. The image is stock <code>python:3.12-alpine</code>, so there's no build step.</p>
<pre><code class="language-yaml">services:
  broken:
    image: python:3.12-alpine
    command: ["python", "-m", "http.server", "8000", "--bind", "127.0.0.1"]
    expose: ["8000"]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/"]
      interval: 10s
      retries: 3
</code></pre>
<p>The stack deployed in 33 seconds:</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/83d63b48-2704-4916-8bd1-1dc18906a7bc.jpg" alt="Hubfly Compose deployment timeline: the stack finished in 33 seconds" style="display:block;margin:0 auto" />

<p>Then add a public HTTPS domain on port 8000 (Network → Add Domain → Auto Endpoint) and open it:</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/c87506e3-9286-49b9-986a-34e593c32479.jpg" alt="The public URL returns HubProxy's Error 502 Bad Gateway page" style="display:block;margin:0 auto" />

<p>Note the page's advice: "This is usually temporary, try again in a few seconds." For a bind bug, retrying forever won't help. It's a configuration error, not a blip.</p>
<h2>Step 1: Prove it's the bind address (30 seconds)</h2>
<p>Open a shell in the container. On Hubfly that's the container's <strong>Console</strong> tab. Elsewhere, use <code>docker exec -it &lt;container&gt; sh</code>.</p>
<p>Run these four commands:</p>
<pre><code class="language-bash">netstat -tln        # or: ss -ltn
IP=$(ip -4 -o addr show eth0 | awk '{print $4}' | cut -d/ -f1); echo $IP
wget -qO- http://127.0.0.1:8000/ | head -c 40; echo
wget -qO- http://$IP:8000/ | head -c 40; echo
</code></pre>
<p>Here is the output from the broken container:</p>
<pre><code class="language-text">/ # netstat -tln
Proto Recv-Q Send-Q Local Address    Foreign Address   State
tcp        0      0 127.0.0.1:8000   0.0.0.0:*         LISTEN
/ # IP=$(ip -4 -o addr show eth0 | awk '{print $4}' | cut -d/ -f1); echo $IP
10.240.82.2
/ # wget -qO- http://127.0.0.1:8000/ | head -c 40; echo
&lt;!DOCTYPE HTML&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
/ # wget -qO- http://$IP:8000/ | head -c 40; echo
wget: can't connect to remote host (10.240.82.2): Connection refused
</code></pre>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/10a7cccf-599e-40ab-8cb0-45dcf29768ef.png" alt="Console output: listening on 127.0.0.1:8000, loopback works, the container IP is refused" style="display:block;margin:0 auto" />

<p>How to read it:</p>
<table>
<thead>
<tr>
<th><code>Local Address</code> in netstat</th>
<th>Reachable from the proxy?</th>
</tr>
</thead>
<tbody><tr>
<td><code>127.0.0.1:8000</code> or <code>::1:8000</code></td>
<td>No. Loopback only. This is your 502.</td>
</tr>
<tr>
<td><code>0.0.0.0:8000</code> or <code>:::8000</code></td>
<td>Yes, on every interface (<code>::</code> usually accepts IPv4 too).</td>
</tr>
<tr>
<td>A specific IP such as <code>10.240.82.2:8000</code></td>
<td>Yes, but it breaks if the IP changes on redeploy.</td>
</tr>
</tbody></table>
<p>If loopback answers and the <code>eth0</code> IP is refused, you've found it. Use <code>$IP</code> from <code>ip</code>, not <code>hostname -i</code>: in this container <code>hostname -i</code> prints <code>127.0.1.1</code>, which is another loopback address and proves nothing.</p>
<h2>Step 2: Fix it in one line</h2>
<p>Change the bind address to <code>0.0.0.0</code>. Here that is one argument:</p>
<pre><code class="language-yaml">command: ["python", "-m", "http.server", "8000", "--bind", "0.0.0.0", "--directory", "/srv"]
</code></pre>
<p>On Hubfly, edit <strong>Configuration → Process Settings → Command</strong> (one argument per line) and save. The new command takes effect, and the same public URL returns 200 within about 20 seconds. <code>netstat</code> now shows:</p>
<pre><code class="language-text">tcp        0      0 0.0.0.0:8000     0.0.0.0:*         LISTEN
</code></pre>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/24cb66e3-d20a-4999-a58a-db5b88a92ea6.png" alt="After the fix: netstat shows the server listening on 0.0.0.0:8000" style="display:block;margin:0 auto" />

<p>Why <code>--directory /srv</code>? See trap 3 below. It's the part most people miss.</p>
<h2>The one-line fix for 9 common servers</h2>
<p>Most of these default to loopback because they're built for your laptop, where loopback is the safe choice. In a container, it's the wrong one.</p>
<table>
<thead>
<tr>
<th>Server</th>
<th>Default you get</th>
<th>Container-friendly command</th>
</tr>
</thead>
<tbody><tr>
<td>Flask dev server</td>
<td><code>127.0.0.1:5000</code></td>
<td><code>flask run --host 0.0.0.0</code></td>
</tr>
<tr>
<td>Django <code>runserver</code> (dev only)</td>
<td><code>127.0.0.1:8000</code></td>
<td><code>python manage.py runserver 0.0.0.0:8000</code></td>
</tr>
<tr>
<td>Uvicorn (FastAPI)</td>
<td><code>127.0.0.1:8000</code></td>
<td><code>uvicorn app:app --host 0.0.0.0 --port 8000</code></td>
</tr>
<tr>
<td>Gunicorn</td>
<td><code>127.0.0.1:8000</code> (or <code>0.0.0.0:$PORT</code> if <code>PORT</code> is set)</td>
<td><code>gunicorn -b 0.0.0.0:8000 app:app</code></td>
</tr>
<tr>
<td>Node <code>http</code> / Express</td>
<td>all interfaces if you omit the host</td>
<td><code>app.listen(3000)</code> or <code>app.listen(3000, "0.0.0.0")</code>. Never <code>"localhost"</code>.</td>
</tr>
<tr>
<td>Vite dev / preview</td>
<td><code>localhost</code></td>
<td><code>vite --host 0.0.0.0</code> or <code>vite preview --host 0.0.0.0</code></td>
</tr>
<tr>
<td>Go <code>net/http</code></td>
<td>whatever you pass</td>
<td><code>http.ListenAndServe(":8080", h)</code>, not <code>"localhost:8080"</code></td>
</tr>
<tr>
<td>PHP built-in server</td>
<td>whatever you pass</td>
<td><code>php -S 0.0.0.0:8000</code>, not <code>localhost:8000</code></td>
</tr>
<tr>
<td>Rails (development)</td>
<td><code>localhost:3000</code></td>
<td><code>bin/rails server -b 0.0.0.0</code></td>
</tr>
</tbody></table>
<p>Better still, read the host and port from environment variables (<code>HOST=0.0.0.0</code>, <code>PORT=8000</code>) so the same image runs on your laptop and in production.</p>
<h2>What can go wrong</h2>
<p><strong>1. The dashboard says the port is open, and it's still a 502.</strong></p>
<p>While the broken container is running, Hubfly's Add Domain dialog lists <code>8000</code> under "Suggested ports" as <strong>"Open now"</strong>. The scan counts a loopback-only listener as open. "Open" means something listens on 8000; it doesn't mean the proxy can reach it. Trust <code>netstat</code>'s Local Address column instead.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/34ca2ae6-f10e-4910-90b1-56bfc401023e.png" alt="Add Domain suggests port 8000 as Open now while the app is bound to 127.0.0.1" style="display:block;margin:0 auto" />

<p><strong>2. Your healthcheck can't see this bug.</strong></p>
<p>A healthcheck that runs <code>wget http://127.0.0.1:8000/</code> inside the container runs on loopback, so a loopback-only server passes it. Run that exact command in the broken container and you get HTML back. (On Hubfly the health badge may show "Unknown" rather than green, but the check itself passes.)</p>
<p>In-container checks can't catch this bug. Check from outside too: open the public URL, or check from another container: set an env var <code>TARGET={{service:broken.host}}</code> on it, then run <code>wget -qO- http://$TARGET:8000/</code> in its Console.</p>
<p><strong>3. The fix can publish your whole filesystem.</strong></p>
<p>Changing only <code>127.0.0.1</code> to <code>0.0.0.0</code> is not enough. The public URL then shows a directory listing of the container's <code>/</code>: <code>bin/</code>, <code>etc/</code>, <code>root/</code> and more. <code>python -m http.server</code> serves the working directory, which is <code>/</code>. Adding <code>--directory /srv</code> fixes it.</p>
<p>The lesson is bigger than Python: once you bind to <code>0.0.0.0</code>, whatever that port serves is public through your domain. Look at the page before you share the link.</p>
<p>Two more causes of 502 that look the same from the browser:</p>
<ul>
<li><p><strong>Wrong port:</strong> the domain points at 80, the app listens on 8000. Match the domain's container port to <code>netstat</code>.</p>
</li>
<li><p><strong>The app crashed:</strong> <code>netstat</code> shows nothing listening at all. Check the logs for a stack trace, not the bind address.</p>
</li>
</ul>
<h2>Takeaway</h2>
<p>A 502 from a container that's "running" is usually a bind address, not an outage. Run <code>netstat -tln</code>. If you see <code>127.0.0.1</code>, change it to <code>0.0.0.0</code>, then check what that port now serves to the world.</p>
<p><strong>Read next:</strong> <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=docker-502-bad-gateway-bind-0-0-0-0">Gate your app on a Postgres migration job with depends_on: service_completed_successfully</a>. It covers the other classic "it's running but broken" Compose bug. More guides are on the <a href="https://blog.hubfly.space/?utm_source=hashnode&amp;utm_campaign=docker-502-bad-gateway-bind-0-0-0-0">Hubfly blog</a>.</p>
<h2>Deploy this</h2>
<p>Want to see the bug and the fix side by side? Paste the Compose file above into <strong>Create → Compose Stack → YAML</strong> in the <a href="https://dashboard.hubfly.space">Hubfly dashboard</a>, add a domain on port 8000, and watch the 502. Then replace the command with the fixed one from Step 2, including <code>--directory /srv</code>. Changing only the bind address publishes the container's whole filesystem. The shared tier (0.3 vCPU, 256 MB) is enough, and it falls inside the free 360 compute hours a month. See <a href="https://www.hubfly.space/pricing">pricing</a> and the <a href="https://www.hubfly.space/docs/cli">CLI docs</a> if you'd rather do it from a terminal.</p>
]]></content:encoded></item><item><title><![CDATA[Let your coding agent deploy to Hubfly, not delete your data: 1 skill, 3 permission lists]]></title><description><![CDATA[Originally published on the Hubfly blog.

TL;DR

What you get: a coding agent that can inspect, plan and deploy your app on Hubfly, but has to ask before it deploys, and is blocked from the usual comm]]></description><link>https://octave.hashnode.dev/let-your-coding-agent-deploy-to-hubfly-not-delete-your-data-1-skill-3-permission-lists</link><guid isPermaLink="true">https://octave.hashnode.dev/let-your-coding-agent-deploy-to-hubfly-not-delete-your-data-1-skill-3-permission-lists</guid><category><![CDATA[AI]]></category><category><![CDATA[Devops]]></category><category><![CDATA[claude-code]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Fri, 16 Oct 2026 22:00:18 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the</em> <a href="https://blog.hubfly.space/coding-agent-deploy-safely-hubfly?utm_source=hashnode&amp;utm_campaign=coding-agent-deploy-safely-hubfly"><em>Hubfly blog</em></a><em>.</em></p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><p><strong>What you get:</strong> a coding agent that can inspect, plan and deploy your app on Hubfly, but has to ask before it deploys, and is blocked from the usual commands that delete volumes or read your token (a fence, not a vault: see the limits below).</p>
</li>
<li><p><strong>Who it's for:</strong> solo devs and small teams already using Claude Code, Codex or Cursor who are tired of copy-pasting deploy commands.</p>
</li>
<li><p><strong>Time:</strong> about 15 minutes. <strong>Cost:</strong> the skill is free; you pay only for what gets deployed (≈ $3.71/month for one always-on 1 vCPU / 512 MB container, math below).</p>
</li>
</ul>
</blockquote>
<h2>The problem</h2>
<p>Your agent wrote the feature, ran the tests and opened the PR. Then it stops and hands you a list of deploy commands to paste. So you give it shell access to your cloud CLI, and now the same agent that once deleted your <code>node_modules</code> "to clean up" can also run <code>stack down --volumes</code>.</p>
<p>The question isn't "should the agent deploy?" It's "which commands can it run alone, which need a yes, and which should never run at all?"</p>
<h2>What Hubfly gives the agent</h2>
<p>Hubfly publishes a <strong>CLI skill</strong> for coding agents at <a href="https://www.hubfly.space/ai">hubfly.space/ai</a>. It's a plain <code>SKILL.md</code> in the portable Agent Skills format, plus three reference files (API, networking, diagnostics). Hubfly says it contains no credentials or executable scripts.</p>
<p>The skill teaches the agent a 5-step loop:</p>
<ol>
<li><p><strong>Inspect:</strong> read the app, the <code>hubfly.build.json</code> manifest, ports, volumes and env var <em>names</em>.</p>
</li>
<li><p><strong>Reconcile:</strong> compare local settings with the container already running in the cloud.</p>
</li>
<li><p><strong>Plan:</strong> review the server-side plan and any destructive operations before rolling out.</p>
</li>
<li><p><strong>Authorize:</strong> ask you when a new cost, a new target or a destructive action needs a decision.</p>
</li>
<li><p><strong>Follow:</strong> track the deployment session and report the real final state or the exact phase that failed.</p>
</li>
</ol>
<p>Three rules in the skill do most of the safety work:</p>
<ul>
<li><p><code>--yes</code> only suppresses a CLI prompt. <strong>It is not permission.</strong> Your request decides what the agent may do.</p>
</li>
<li><p><code>hubfly stack down --volumes</code> deletes durable data and needs a separate confirmation that names the volumes.</p>
</li>
<li><p>Never print secret values or tokens; avoid <code>printenv</code>. A local build or upload is not "deployed" until the Dashboard says so.</p>
</li>
</ul>
<p>That's the agent's conscience. Now give it a fence, because a conscience is just a prompt.</p>
<h2>Walkthrough: Claude Code + Hubfly in 4 steps</h2>
<h3>1. Install the CLI and log in yourself</h3>
<pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/hubfly-space/hubfly-cli/main/install.sh | bash
hubfly version
hubfly login      # get a token at dashboard.hubfly.space/cli/auth and paste it
hubfly whoami
</code></pre>
<p>Log in <strong>yourself</strong>, in your own terminal. The token lands in <code>~/.hubfly/config.json</code>. Don't paste it into the chat and don't commit it.</p>
<h3>2. Add the skill to your repo</h3>
<pre><code class="language-bash">mkdir -p .claude/skills/hubfly-cli/references
curl -fsSL https://hubfly.space/skills/hubfly-cli/SKILL.md \
  -o .claude/skills/hubfly-cli/SKILL.md
for ref in api networking diagnostics; do
  curl -fsSL https://hubfly.space/skills/hubfly-cli/references/$ref.md \
    -o .claude/skills/hubfly-cli/references/$ref.md
done
</code></pre>
<p>For Codex use <code>.codex/skills/hubfly-cli/</code>, for Cursor <code>.cursor/skills/hubfly-cli/</code>, or the portable <code>.agents/skills/hubfly-cli/</code>. Read the file before you commit it. It's short, and you should know what your agent was told.</p>
<p>The skill also asks agents to re-fetch the live <code>SKILL.md</code> for each task and to add a small hubfly block to <code>CLAUDE.md</code> or <code>AGENTS.md</code>. A safer habit is to commit your reviewed copy and re-pull it when Hubfly updates it, so a change to the live file never reaches your agent unread.</p>
<h3>3. Write the fence: <code>.claude/settings.json</code></h3>
<pre><code class="language-json">{
  "permissions": {
    "allow": [
      "Bash(hubfly whoami)",
      "Bash(hubfly version)",
      "Bash(hubfly stack status *)",
      "Bash(hubfly stack plan *)",
      "Bash(hubfly build validate *)",
      "Bash(hubfly build explain *)",
      "Bash(hubfly logs *)",
      "Bash(hubfly stack logs *)"
    ],
    "ask": [
      "Bash(hubfly deploy *)",
      "Bash(hubfly stack up *)",
      "Bash(hubfly exec *)",
      "Bash(hubfly ssh *)",
      "Bash(hubfly tunnel *)",
      "Bash(hubfly stack exec *)",
      "Bash(hubfly stack ssh *)",
      "Bash(hubfly container *)",
      "Bash(hubfly compose *)"
    ],
    "deny": [
      "Bash(hubfly stack down *)",
      "Bash(hubfly stack down --volumes *)",
      "Bash(hubfly compose down *)",
      "Bash(hubfly logout *)",
      "Bash(hubfly login *)",
      "Bash(printenv *)",
      "Read(~/.hubfly/**)"
    ]
  }
}
</code></pre>
<p>The skill also lists a <code>hubfly compose</code> alias and <code>container</code> subcommands, so fence those too. <code>login</code> is denied: you log in, never the agent. This file is Claude Code's format. Codex and Cursor have their own approval settings; the same allow/ask/deny split applies.</p>
<p>How Claude Code reads this, per its <a href="https://code.claude.com/docs/en/permissions">permissions docs</a>:</p>
<ul>
<li><p>Rules are checked <strong>deny, then ask, then allow</strong>. The first match wins, and specificity doesn't change the order.</p>
</li>
<li><p>So <code>hubfly deploy plan</code> will also prompt, because it matches the <code>ask</code> rule first. That's on purpose: the skill warns <code>deploy plan</code> isn't guaranteed read-only (it can bind or create a remote project), and it isn't in the CLI docs yet. Read it before you approve.</p>
</li>
<li><p><code>Bash(hubfly logs *)</code> also matches the bare <code>hubfly logs</code>. The space before <code>*</code> matters.</p>
</li>
<li><p><code>Read(~/.hubfly/**)</code> keeps the token file out of the agent's context, and also blocks edits to it.</p>
</li>
<li><p>Allow rules in a committed <code>.claude/settings.json</code> only apply after you accept the workspace trust prompt. Deny and ask rules apply right away.</p>
</li>
</ul>
<p><code>stack down</code> is denied, not asked. If a stack needs tearing down, that's one command worth typing yourself.</p>
<h3>4. Ask for a deploy</h3>
<p>Start Claude Code in the repo and try one of the prompts Hubfly suggests:</p>
<blockquote>
<p>Deploy my web app with HTTPS and keep Postgres private with tunnel access.</p>
</blockquote>
<p>What you should see, if the skill is doing its job:</p>
<ul>
<li><p>the allow-listed commands (<code>whoami</code>, <code>stack plan</code>, <code>build explain</code>) run without prompts once you've trusted the workspace;</p>
</li>
<li><p>a plan that lists services, ports and volumes, with the database <strong>not</strong> exposed publicly;</p>
</li>
<li><p>a permission prompt on <code>hubfly stack up</code> or <code>hubfly deploy</code>, which is your moment to read the plan;</p>
</li>
<li><p>a final report that names the Dashboard's terminal state, not just "image uploaded".</p>
</li>
</ul>
<p>For a Compose app, the reviewed plan is <code>hubfly stack plan --json</code>. If you want to see how a multi-service deploy gates on a migration job, the <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=coding-agent-deploy-safely-hubfly">migration-job tutorial</a> walks through it end to end.</p>
<h2>Add a money fence on the Hubfly side</h2>
<p>Permission rules match the command text the agent writes. Claude Code's own docs say a deny rule isn't a security boundary: <code>sh -c 'hubfly stack down ...'</code> or a full path can slip past it. So put a second fence where the agent can't edit it:</p>
<ul>
<li><p><strong>Project budget limit.</strong> When you create a project, Advanced options offer a monthly budget limit with presets of $10, $50 and $100. Pick the smallest one that fits. Hubfly's pricing page says a budget cap stops workloads when it's hit (the billing docs don't cover it yet). How quickly it stops them isn't documented, so treat it as a ceiling, not a real-time alarm.</p>
</li>
<li><p><strong>One project for agent work.</strong> Point the agent at it with <code>--project</code> and say so in your prompt. The skill treats a new target as something to ask about.</p>
</li>
<li><p><strong>Audit log.</strong> Hubfly logs redeploys, env var changes, member role updates and API key generations, with UTC timestamp, IP, actor email and session, the resource changed, and a before/after diff. Check it after the agent's first few runs.</p>
</li>
</ul>
<h2>What can go wrong</h2>
<ol>
<li><p><strong>The agent adds</strong> <code>--yes</code> <strong>and you assume it skipped your approval.</strong> It didn't. <code>--yes</code> skips the <em>CLI's</em> prompt. Claude Code's <code>ask</code> rule still fires on <code>hubfly deploy *</code>. If you never see a prompt, check that you're not in bypass-permissions mode and that <code>/permissions</code> still shows the <code>ask</code> rule. An allow rule can't override an ask rule.</p>
</li>
<li><p><strong>Secrets end up in the transcript.</strong> An agent debugging env vars loves <code>printenv</code>. The deny rule and the skill both block the obvious form, but <code>hubfly exec web -- env</code> is another. That's why <code>exec</code> is in <code>ask</code>: read the command before you approve it.</p>
</li>
<li><p><strong>The agent "succeeds" at the wrong layer.</strong> A local build or upload isn't a deploy. If the report doesn't mention the Dashboard's final state or a health check, ask for <code>hubfly stack status</code> before you trust it.</p>
</li>
</ol>
<h2>Limits and pricing, honestly</h2>
<p><strong>What it costs.</strong> The skill and CLI are free. You pay Hubfly's normal rates for what the agent deploys: \(0.004 per vCPU-hour, \)0.002 per GB-hour of RAM, $0.00001536 per GB-hour of storage. One always-on <strong>dedicated</strong> container (1 vCPU, 512 MB, 5 GB) over 730 hours:</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Math</th>
<th>Monthly</th>
</tr>
</thead>
<tbody><tr>
<td>CPU</td>
<td>1 × $0.004 × 730</td>
<td>$2.92</td>
</tr>
<tr>
<td>RAM</td>
<td>0.5 GB × $0.002 × 730</td>
<td>$0.73</td>
</tr>
<tr>
<td>Storage</td>
<td>5 GB × $0.00001536 × 730</td>
<td>$0.06</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td><strong>≈ $3.71</strong></td>
</tr>
</tbody></table>
<p>A <strong>shared</strong>-tier container auto-sleeps when idle and is free within the 360 compute hours included each month, which is plenty for preview deploys.</p>
<p><strong>What it doesn't do (yet):</strong></p>
<ul>
<li><p>The CLI docs don't describe token scopes. A personal access token acts as you, so the fences above matter.</p>
</li>
<li><p>There's no documented approval queue on Hubfly's side. The approval step lives in your agent's permission settings.</p>
</li>
<li><p>Backups, snapshots and an SLA aren't documented yet. Keep your own database backups before letting anything automated near production.</p>
</li>
</ul>
<h2>Who this is NOT for</h2>
<ul>
<li><p><strong>Teams that need per-project, least-privilege API tokens today.</strong> Wait for documented scopes, or keep the agent on a non-production project.</p>
</li>
<li><p><strong>Production databases with no backup.</strong> Fix that first. No permissions file replaces a restore you've tested.</p>
</li>
<li><p><strong>Fully unattended CI deploys.</strong> This setup assumes a human answers the <code>ask</code> prompt. For CI, use <code>HUBFLY_TOKEN</code> as a secret and a normal pipeline.</p>
</li>
</ul>
<h2>Takeaway</h2>
<p>Let the agent read everything, make it ask before it deploys, and block the commands that delete. The Hubfly skill gives it the right habits. <code>.claude/settings.json</code> gives you the prompt. A project budget limit gives you a ceiling the agent can't argue with.</p>
<p><strong>Read next:</strong> <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=coding-agent-deploy-safely-hubfly">Gate your app on a Postgres migration job</a> · <a href="https://blog.hubfly.space/how-to-deploy-odoo-on-hubfly-space-with-postgresql-and-persistent-storage?utm_source=hashnode&amp;utm_campaign=coding-agent-deploy-safely-hubfly">Deploy Odoo with PostgreSQL and persistent storage</a> · <a href="https://www.hubfly.space/docs/cli">Hubfly CLI docs</a></p>
<p><strong>Start free, 360 hours included.</strong> Install the skill, point your agent at a fresh project with a $10 budget limit, and let it ship your next preview. <a href="https://dashboard.hubfly.space/?utm_source=hashnode&amp;utm_medium=post&amp;utm_campaign=agent-deploy">Create your Hubfly account →</a></p>
]]></content:encoded></item><item><title><![CDATA[Heroku vs Render vs Railway vs Hubfly: 4 prices for one app + Postgres ($7–$20/month)]]></title><description><![CDATA[Originally published on the Hubfly blog.
You have one small app and a Postgres database, and four pricing pages that use four different units. Here is what that setup costs on each platform, with the ]]></description><link>https://octave.hashnode.dev/heroku-vs-render-vs-railway-vs-hubfly-4-prices-for-one-app-postgres-7-20-month</link><guid isPermaLink="true">https://octave.hashnode.dev/heroku-vs-render-vs-railway-vs-hubfly-4-prices-for-one-app-postgres-7-20-month</guid><category><![CDATA[Heroku]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Cloud Computing]]></category><category><![CDATA[render]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Fri, 16 Oct 2026 21:57:55 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the</em> <a href="https://blog.hubfly.space/heroku-vs-render-vs-railway-vs-hubfly-pricing?utm_source=hashnode&amp;utm_campaign=heroku-vs-render-vs-railway-vs-hubfly-pricing"><em>Hubfly blog</em></a><em>.</em></p>
<p>You have one small app and a Postgres database, and four pricing pages that use four different units. Here is what that setup costs on each platform, with the math shown.</p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><p><strong>What you get:</strong> the monthly list price of the same small setup (one web app with 512 MB of RAM, one Postgres database with 1 GB of disk, running 24/7) on four platforms, with every line of math.</p>
</li>
<li><p><strong>Who it's for:</strong> solo developers and small teams picking a home for a side project or a first production app.</p>
</li>
<li><p><strong>Time needed:</strong> 8 minutes to read, 5 minutes to redo the math for your own app.</p>
</li>
<li><p><strong>Short answer:</strong> Heroku $12.00, Render $13.00, Railway about $7.26 for a mostly idle app (more if it's busy), Hubfly about $7.42. The cheapest is not the same as the best fit; the section "Where each one wins" is the part to read.</p>
</li>
</ul>
</blockquote>
<h2>The problem: four pricing models that don't line up</h2>
<p>You have a small app and a Postgres database. You want to know what it will cost before you move.</p>
<p>Then you open four pricing pages. One sells fixed-size dynos. One sells instance plans plus a workspace fee.</p>
<p>One bills per second of CPU and RAM you actually use. One bills per hour of CPU and RAM you allocate. So "which is cheaper?" turns into a spreadsheet afternoon.</p>
<p>This post is that spreadsheet, done once, with links. Prices change, so every price links to the vendor's own pricing page.</p>
<h2>The reference app</h2>
<p>To compare like with like, here is one setup priced everywhere:</p>
<ul>
<li><p><strong>Web app:</strong> one instance, about 512 MB of RAM, always on (730 hours a month).</p>
</li>
<li><p><strong>Database:</strong> Postgres, 1 GB of data, always on.</p>
</li>
<li><p><strong>Traffic:</strong> small. Bandwidth stays out of the totals and is covered separately below.</p>
</li>
<li><p><strong>No free tiers in the totals.</strong> Free tiers sleep or expire, so they get their own section.</p>
</li>
</ul>
<p>A month is 730 hours on every platform.</p>
<h2>The comparison table</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Web app</th>
<th>Postgres</th>
<th>Plan or platform fee</th>
<th>Monthly total</th>
<th>Billing model</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Heroku</strong></td>
<td>Basic dyno, 0.5 GB: <strong>$7.00</strong></td>
<td>Essential-0, 1 GB disk: <strong>$5.00</strong></td>
<td>none</td>
<td><strong>$12.00</strong></td>
<td>Fixed monthly cap per dyno, prorated to the second</td>
</tr>
<tr>
<td><strong>Render</strong></td>
<td>$7 instance, 512 MB, under 1 CPU: <strong>$7.00</strong></td>
<td>Smallest paid plan, 256 MB RAM, 1 GB SSD included: <strong>$6.00</strong></td>
<td>Hobby workspace: <strong>$0</strong></td>
<td><strong>$13.00</strong></td>
<td>Fixed plan price, billed per second</td>
</tr>
<tr>
<td><strong>Railway</strong></td>
<td>Usage-based</td>
<td>Usage-based</td>
<td>Hobby: <strong>$5 minimum</strong>, which covers your first $5 of usage</td>
<td><strong>≈ $7.26</strong> light / <strong>≈ $20.45</strong> busier (see math)</td>
<td>Per second of measured usage</td>
</tr>
<tr>
<td><strong>Hubfly</strong></td>
<td>Dedicated tier, 1 vCPU, 512 MB: <strong>≈ $3.71</strong></td>
<td>Postgres container, same tier: <strong>≈ $3.71</strong> • 1 GB volume <strong>≈ $0.01</strong></td>
<td>none</td>
<td><strong>≈ $7.42</strong></td>
<td>Per hour of allocated CPU and RAM</td>
</tr>
</tbody></table>
<p>Prices come from each vendor's official pricing page, linked in the platform sections below.</p>
<h2>The math, platform by platform</h2>
<h3>Heroku: $12.00</h3>
<ul>
<li><p>Basic dyno: $7 a month, 0.5 GB RAM (<a href="https://www.heroku.com/pricing">pricing</a>).</p>
</li>
<li><p>Heroku Postgres Essential-0: $5 a month, 1 GB disk (<a href="https://www.heroku.com/pricing">pricing</a>).</p>
</li>
<li><p>\(7 + \)5 = <strong>$12.00</strong>.</p>
</li>
</ul>
<p>Heroku "prorates all costs to the second". The listed price is the <em>maximum</em> you pay for a dyno that runs all month (<a href="https://devcenter.heroku.com/articles/usage-and-billing">usage and billing</a>).</p>
<p>Billing is on wall-clock time, not CPU time, so an idle dyno costs the same as a busy one.</p>
<p><strong>Cheaper Heroku option:</strong> Eco dynos cost $5 for a pool of 1,000 dyno hours a month, shared by all Eco dynos in your account. An Eco web dyno sleeps after 30 minutes without web traffic (<a href="https://devcenter.heroku.com/articles/eco-dyno-hours">Eco dyno hours</a>). Eco plus Essential-0 is $10, if you can live with the sleep.</p>
<h3>Render: $13.00</h3>
<ul>
<li><p>Smallest paid instance: $7 a month, 512 MB RAM, listed as under 1 CPU (<a href="https://render.com/pricing">pricing</a>).</p>
</li>
<li><p>Smallest paid Postgres: $6 a month, 256 MB RAM, under 1 CPU. The page lists "1 GB SSD storage included" and "Expandable storage ($0.30/GB)" (<a href="https://render.com/pricing">pricing</a>).</p>
</li>
<li><p>Hobby workspace: $0 a month plus compute (<a href="https://render.com/pricing">pricing</a>).</p>
</li>
<li><p>\(7 + \)6 + $0 = <strong>$13.00</strong>.</p>
</li>
</ul>
<p>If your data grows to 10 GB, add 9 GB × \(0.30 = \)2.70.</p>
<p>If you need team features, the Pro workspace is $25 a month on top of compute (<a href="https://render.com/pricing">pricing</a>). That one line changes the answer for teams, so check which workspace plan you need.</p>
<h3>Railway: about $7.26 light, about $20.45 busier</h3>
<p>Railway bills resources per second (<a href="https://railway.com/pricing">pricing</a>):</p>
<ul>
<li><p>vCPU: $0.00000772 per vCPU-second</p>
</li>
<li><p>Memory: $0.00000386 per GB-second</p>
</li>
<li><p>Volume: $0.00000006 per GB-second</p>
</li>
</ul>
<p>Converted to a 730-hour month (× 3,600 × 730):</p>
<ul>
<li><p>vCPU: $0.00000772 × 2,628,000 = <strong>$20.29 per vCPU-month</strong></p>
</li>
<li><p>Memory: $0.00000386 × 2,628,000 = <strong>$10.14 per GB-month</strong></p>
</li>
<li><p>Volume: $0.00000006 × 2,628,000 = <strong>$0.16 per GB-month</strong></p>
</li>
</ul>
<p>The Hobby plan is a $5 minimum usage commitment that covers your first $5 of usage. A $3 usage month bills $5; an $8 month bills $8 (<a href="https://docs.railway.com/pricing/understanding-your-bill">understanding your bill</a>).</p>
<p>Because you pay for what the app and database actually use, here are two cases:</p>
<ul>
<li><p><strong>Light</strong> (app and database together average 0.1 vCPU and 0.5 GB RAM): 0.1 × \(20.29 + 0.5 × \)10.14 + 1 × \(0.16 = \)2.03 + \(5.07 + \)0.16 = <strong>$7.26</strong>.</p>
</li>
<li><p><strong>Busier</strong> (together 0.5 vCPU and 1 GB RAM): 0.5 × \(20.29 + 1 × \)10.14 + \(0.16 = \)10.14 + \(10.14 + \)0.16 = <strong>$20.45</strong>.</p>
</li>
</ul>
<p>Your real bill sits wherever your usage graph sits. That is Railway's main strength and its main uncertainty.</p>
<h3>Hubfly: about $7.42</h3>
<p>Hubfly bills allocated CPU and RAM by the hour (<a href="https://hubfly.space/pricing">pricing</a>):</p>
<ul>
<li><p>CPU: $0.004 per vCPU-hour</p>
</li>
<li><p>RAM: $0.002 per GB-hour</p>
</li>
<li><p>Storage: \(0.00001536 per GB-hour (about \)0.011 per GB-month); the Balanced tier is 1.5× and High Performance is 2×</p>
</li>
</ul>
<p>The dedicated tier defaults to 1 vCPU, 512 MB RAM and 5 GB of disk, always on (<a href="https://hubfly.space/docs">docs</a>). Per container:</p>
<ul>
<li><p>CPU: 1 × \(0.004 × 730 = \)2.92</p>
</li>
<li><p>RAM: 0.5 × \(0.002 × 730 = \)0.73</p>
</li>
<li><p>Disk: 5 × \(0.00001536 × 730 = \)0.06</p>
</li>
<li><p><strong>Per container ≈ $3.71</strong></p>
</li>
</ul>
<p>App plus Postgres container: 2 × $3.706 ≈ $7.41. If you add a separate 1 GB volume for the data, add about $0.01 at the base rate. <strong>Total ≈ $7.42.</strong></p>
<p>You can size containers differently from the tier default. Hubfly's pricing page shows a 0.5 vCPU, 1 GB, 10 GB disk container running 24/7 at about $3.03 a month (<a href="https://hubfly.space/pricing">pricing</a>).</p>
<p>The math: 0.5 × \(0.004 × 730 = \)1.46, plus 1 × \(0.002 × 730 = \)1.46, plus 10 × \(0.011 = \)0.11.</p>
<h2>Where each one wins</h2>
<p>A cheaper total doesn't settle it. Here is where each platform is the better pick, based on what their own pages say.</p>
<p><strong>Heroku wins on a managed Postgres product and an add-on marketplace.</strong> Heroku Postgres is sold as its own product, and the Elements Marketplace lists add-ons (<a href="https://www.heroku.com/pricing">pricing</a>). If your team already runs on Heroku, staying costs you no migration work.</p>
<p><strong>Render wins on managed Postgres features at a low price.</strong> Render's Hobby workspace lists "Database PITR" (point-in-time recovery, 3-day window; 7 days on Pro), custom domains, and firewall and DDoS mitigation at $0 plus compute (<a href="https://render.com/pricing">pricing</a>). Point-in-time recovery on a $6 database is a real feature. Hubfly does not document backups or snapshots yet.</p>
<p><strong>Railway wins when your app is idle most of the time.</strong> Usage billing means a quiet app pays for the little it uses. Railway's Serverless setting can also put a service to sleep after 5 to 10 minutes without outbound traffic (<a href="https://docs.railway.com/reference/app-sleeping">app sleeping</a>).</p>
<p><strong>Hubfly wins on raw allocated CPU per dollar and on fixed, predictable math.</strong> One full vCPU with 512 MB for about $3.71 a month is cheap. The bill is a straight line: allocation × rate × hours, with no workspace or seat fee. Projects can have a monthly budget cap that stops workloads when it is reached (<a href="https://hubfly.space/docs">docs</a>).</p>
<p><strong>Where Hubfly loses today, in plain words:</strong></p>
<ul>
<li><p>Postgres on Hubfly is a container from a template, with a volume. Backups and snapshots aren't documented, so plan your own <code>pg_dump</code> schedule.</p>
</li>
<li><p>Fewer regions: Europe (two) and the central US at the time of writing.</p>
</li>
<li><p>A smaller ecosystem than Heroku's add-ons.</p>
</li>
<li><p>Allocation billing means an idle 1 vCPU container still costs $2.92 of CPU a month. On Railway, an idle app costs less.</p>
</li>
</ul>
<h2>Free tiers: what you actually get</h2>
<p>None of these are in the totals, because each one sleeps, expires or caps out.</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>What's free</th>
<th>The catch</th>
<th>Source</th>
</tr>
</thead>
<tbody><tr>
<td>Heroku</td>
<td>No free dynos; Eco is the cheapest at $5 for 1,000 hours</td>
<td>Eco web dynos sleep after 30 minutes without traffic</td>
<td><a href="https://devcenter.heroku.com/articles/eco-dyno-hours">Eco dyno hours</a></td>
</tr>
<tr>
<td>Render</td>
<td>Free web service and free Postgres</td>
<td>Web service spins down after 15 minutes idle, about 1 minute to spin up; 750 free hours a month; free Postgres expires after 30 days, then a 14-day grace period before deletion</td>
<td><a href="https://render.com/docs/free">Render free</a></td>
</tr>
<tr>
<td>Railway</td>
<td>Free plan: $5 trial credits for 30 days, then $1 a month</td>
<td>Small, credit-based</td>
<td><a href="https://railway.com/pricing">pricing</a></td>
</tr>
<tr>
<td>Hubfly</td>
<td>360 shared-tier hours a month plus 1 GB storage, no card</td>
<td>Shared containers auto-sleep when idle</td>
<td><a href="https://hubfly.space/pricing">pricing</a></td>
</tr>
</tbody></table>
<p>Note the Render database one. A free database that's deleted after 44 days is fine for a quick experiment and dangerous for anything else.</p>
<h2>Bandwidth: the line item that breaks totals</h2>
<ul>
<li><p><strong>Render:</strong> Hobby includes 5 GB a month, then $0.15 per GB (<a href="https://render.com/pricing">pricing</a>).</p>
</li>
<li><p><strong>Railway:</strong> $0.05 per GB of egress (<a href="https://railway.com/pricing">pricing</a>).</p>
</li>
<li><p><strong>Heroku and Hubfly:</strong> the pages linked here list no per-GB egress rate. Check before you plan heavy traffic.</p>
</li>
</ul>
<p>At 50 GB a month, Render adds 45 × \(0.15 = \)6.75 and Railway adds 50 × \(0.05 = \)2.50. For a media-heavy app, bandwidth can outweigh compute.</p>
<h2>What can go wrong</h2>
<ol>
<li><p><strong>You compare allocation with usage.</strong> Railway's light case assumes a quiet app. If your app holds 1 GB of RAM all month, Railway's memory line alone is $10.14. Fix: check your current app's real RAM graph before you trust any total.</p>
</li>
<li><p><strong>The database outgrows the plan.</strong> Heroku Essential-0 has 1 GB of disk; Render's $6 plan includes 1 GB, then $0.30 per GB. Fix: price the database at the size it will be in six months, not today.</p>
</li>
<li><p><strong>The workspace or team fee appears later.</strong> Render Pro is $25 a month; Railway Pro is a $20 minimum. Fix: add the plan you'll need once a second person joins.</p>
</li>
</ol>
<h2>Takeaway</h2>
<p>For one small app with Postgres running all month, list prices land between about $7 and $13, or about $20 on Railway if the app is busy. Hubfly and a quiet Railway app are cheapest. Heroku and Render cost a few dollars more and include a managed Postgres product. Only Render's includes point-in-time recovery at this price.</p>
<p>Pick by the line you can't live without:</p>
<ul>
<li><p>Managed Postgres with point-in-time recovery at this price: <strong>Render</strong> (Heroku offers rollback from its Standard-0 Postgres tier up).</p>
</li>
<li><p>Pay only for what a mostly idle app uses: <strong>Railway</strong>.</p>
</li>
<li><p>The lowest fixed price for always-on CPU, with a budget cap: <strong>Hubfly</strong>.</p>
</li>
</ul>
<p><strong>Read next:</strong> <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=heroku-vs-render-vs-railway-vs-hubfly-pricing">Gate your app on a Postgres migration job with depends_on: service_completed_successfully</a>, the Hubfly docs on <a href="https://hubfly.space/docs">Compose stacks</a>, and the <a href="https://hubfly.space/pricing">Hubfly pricing page</a> for the full rate card.</p>
<h2>Price your own app</h2>
<p>Your Hubfly bill is one formula: (vCPU × \(0.004 + GB RAM × \)0.002) × 730 + disk GB × \(0.011. For this post's app that is (1 × 0.004 + 0.5 × 0.002) × 730 + 5 × 0.011 ≈ \)3.71 per container.</p>
<p>Plug in your own numbers, check them against the line items on the <a href="https://hubfly.space/pricing">Hubfly pricing page</a>, and start on the free tier (no card needed).</p>
]]></content:encoded></item><item><title><![CDATA[Make your app wait for its migrations: a Compose migration job]]></title><description><![CDATA[Originally published on the Hubfly blog.

TL;DR

What you get: a 3-service Compose file (Postgres, a one-shot migration job, an API) where the API cannot start until the migration exits 0.

Who it's f]]></description><link>https://octave.hashnode.dev/make-your-app-wait-for-its-migrations-a-compose-migration-job</link><guid isPermaLink="true">https://octave.hashnode.dev/make-your-app-wait-for-its-migrations-a-compose-migration-job</guid><category><![CDATA[Docker]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[octave Nkurunziza]]></dc:creator><pubDate>Fri, 16 Oct 2026 06:37:44 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the</em> <a href="https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&amp;utm_campaign=docker-compose-migration-job-service-completed-successfully"><em>Hubfly blog</em></a><em>.</em></p>
<blockquote>
<p><strong>TL;DR</strong></p>
<ul>
<li><p><strong>What you get:</strong> a 3-service Compose file (Postgres, a one-shot migration job, an API) where the API cannot start until the migration exits 0.</p>
</li>
<li><p><strong>Who it's for:</strong> anyone whose app has ever booted against a database with a missing table.</p>
</li>
<li><p><strong>Time:</strong> about 20 minutes. The rollout itself takes about a minute: 59 seconds from click to "Stack deployment finished".</p>
</li>
</ul>
</blockquote>
<h2>Your app started before your schema did</h2>
<p>You push a release. The API boots, runs its first query, and gets <code>relation "todos" does not exist</code>. The migration ran too, just 3 seconds later.</p>
<p>Most Compose files only say <code>depends_on: [db]</code>. That waits for the database <em>container</em> to start, not for the schema.</p>
<p>Docker's docs say so plainly: Compose waits until a dependency is running, not until it is ready (<a href="https://docs.docker.com/compose/how-tos/startup-order/">Docker docs</a>).</p>
<p>The fix is well known: a healthcheck on Postgres, a one-shot migration service, and <code>condition: service_completed_successfully</code> on the app. What the popular guides skip is what happens when the gate itself misfires. On a real deploy, it misfires on the first try. This post shows the file that works, and why the first version didn't.</p>
<h2>What you're building</h2>
<pre><code class="language-text">db (postgres:16-alpine)          stage 1
  │  healthcheck: pg_isready over TCP
  ▼  condition: service_healthy
migrate (one-shot job, exits 0)  stage 2
  │  waits for DNS + port, then runs idempotent SQL
  ▼  condition: service_completed_successfully
api (PostgREST, serves /todos)   stage 3
</code></pre>
<p>The API is <a href="https://postgrest.org">PostgREST</a>, which turns a Postgres schema into a REST API. It's a good fit here because it reads the schema at boot. If the migration hasn't run, there is nothing to serve.</p>
<p>Hubfly's composer turned these 3 services into 3 deploy stages. It marked <code>migrate</code> as a <strong>Job</strong> with restart policy <strong>Never</strong>, because of <code>restart: "no"</code>.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/67855f42-38b7-429c-b8ae-ce8112cb7a24.png" alt="The stack composer showing db, migrate and api as connected stages" style="display:block;margin:0 auto" />

<h2>Step 1: Postgres with a healthcheck that tells the truth</h2>
<pre><code class="language-yaml">db:
  image: postgres:16-alpine
  environment:
    POSTGRES_DB: app
    POSTGRES_USER: app
    POSTGRES_PASSWORD: change-me   # replace before you deploy
  volumes:
    - pgdata:/var/lib/postgresql/data
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
    interval: 5s
    timeout: 3s
    retries: 10
</code></pre>
<p>Note the <code>-h 127.0.0.1</code>. Without it, <code>pg_isready</code> checks the Unix socket.</p>
<p>On first boot, the official image starts a temporary server to create your database. In the container logs, that temporary server listens <strong>only</strong> on the Unix socket. The real server came up afterwards with <code>listening on IPv4 address "0.0.0.0", port 5432</code>.</p>
<p>A socket check could go green during that setup window, so check over TCP as a precaution. A TCP check only passes once other containers can actually connect.</p>
<h2>Step 2: a migration job that waits, then runs idempotent SQL</h2>
<pre><code class="language-yaml">migrate:
  image: postgres:16-alpine
  restart: "no"
  environment:
    PGHOST: "{{service:db.host}}"
    PGUSER: app
    PGPASSWORD: change-me
    PGDATABASE: app
  command:
    - sh
    - -c
    - |
      timeout 60 sh -c 'until pg_isready -t 2; do echo "waiting for db"; sleep 2; done' || exit 1
      psql -v ON_ERROR_STOP=1 \
        -c "CREATE TABLE IF NOT EXISTS todos (id serial PRIMARY KEY, task text NOT NULL, done boolean NOT NULL DEFAULT false)" \
        -c "DO 'BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ''web_anon'') THEN CREATE ROLE web_anon NOLOGIN; END IF; END'" \
        -c "GRANT USAGE ON SCHEMA public TO web_anon" \
        -c "GRANT SELECT ON todos TO web_anon" \
        -c "INSERT INTO todos (task) SELECT 'ship the migration gate' WHERE NOT EXISTS (SELECT 1 FROM todos)"
  depends_on:
    db:
      condition: service_healthy
</code></pre>
<p>Three details matter here.</p>
<ol>
<li><p><strong>The wait loop.</strong> This is the line that turns a failed deploy into a working one. More on why below. The <code>timeout 60</code> keeps a broken database from hanging the rollout forever; after a minute the job exits 1 and the gate stays closed.</p>
</li>
<li><p><code>ON_ERROR_STOP=1</code><strong>.</strong> Without it, <code>psql</code> happily continues past a failed statement and exits 0. Your gate would open on a half-migrated schema.</p>
</li>
<li><p><strong>Every statement is safe to re-run.</strong> The job runs on every deploy, and the volume keeps your data between deploys. Run it twice against the same database and the second run prints <code>NOTICE: relation "todos" already exists, skipping</code> and <code>INSERT 0 0</code>, then exits 0.</p>
</li>
</ol>
<p><code>{{service:db.host}}</code> is Hubfly's service reference. At deploy time it became the database's private hostname, <code>db-1-7befe5b4.eucentral.hubfly.internal</code>.</p>
<p>On plain Docker Compose, replace <code>{{service:db.host}}</code> with the service name <code>db</code> in both places: <code>PGHOST: db</code> and <code>PGRST_DB_URI: postgres://app:…@db:5432/app</code>.</p>
<p>The <code>DO '...'</code> block uses ordinary quotes instead of <code>$$</code>. That's deliberate: Compose treats <code>$</code> as variable interpolation, and quoted SQL dodges the whole escaping question.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/df085594-00f0-405c-865e-e4eecd39b7f0.png" alt="Re-running the migration on the same volume (NOTICE already exists, INSERT 0 0, EXIT=0)" style="display:block;margin:0 auto" />

<h2>Step 3: the app waits for the job's exit code</h2>
<pre><code class="language-yaml">  api:
    image: postgrest/postgrest:v12.2.3
    environment:
      PGRST_DB_URI: "postgres://app:change-me@{{service:db.host}}:5432/app"
      PGRST_DB_SCHEMAS: public
      PGRST_DB_ANON_ROLE: web_anon
      PGRST_SERVER_PORT: "3000"
    ports:
      - "3000:3000"
    depends_on:
      migrate:
        condition: service_completed_successfully

volumes:
  pgdata:
</code></pre>
<p><code>service_completed_successfully</code> means exit code 0, nothing else. If the job exits 1, the API is never created. That's the whole point: a bad migration stops the release instead of shipping it.</p>
<p>The anonymous role gets <code>SELECT</code> only, so a <code>POST /todos</code> from the browser returns <strong>401</strong> with <code>permission denied for table todos</code>.</p>
<p>For production, have PostgREST log in as a dedicated low-privilege authenticator role instead of the <code>app</code> owner. This file uses one user to stay short.</p>
<p>Here's the complete file:</p>
<pre><code class="language-yaml">services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: change-me
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

  migrate:
    image: postgres:16-alpine
    restart: "no"
    environment:
      PGHOST: "{{service:db.host}}"
      PGUSER: app
      PGPASSWORD: change-me
      PGDATABASE: app
    command:
      - sh
      - -c
      - |
        timeout 60 sh -c 'until pg_isready -t 2; do echo "waiting for db"; sleep 2; done' || exit 1
        psql -v ON_ERROR_STOP=1 \
          -c "CREATE TABLE IF NOT EXISTS todos (id serial PRIMARY KEY, task text NOT NULL, done boolean NOT NULL DEFAULT false)" \
          -c "DO 'BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ''web_anon'') THEN CREATE ROLE web_anon NOLOGIN; END IF; END'" \
          -c "GRANT USAGE ON SCHEMA public TO web_anon" \
          -c "GRANT SELECT ON todos TO web_anon" \
          -c "INSERT INTO todos (task) SELECT 'ship the migration gate' WHERE NOT EXISTS (SELECT 1 FROM todos)"
    depends_on:
      db:
        condition: service_healthy

  api:
    image: postgrest/postgrest:v12.2.3
    environment:
      PGRST_DB_URI: "postgres://app:change-me@{{service:db.host}}:5432/app"
      PGRST_DB_SCHEMAS: public
      PGRST_DB_ANON_ROLE: web_anon
      PGRST_SERVER_PORT: "3000"
    ports:
      - "3000:3000"
    depends_on:
      migrate:
        condition: service_completed_successfully

volumes:
  pgdata:
</code></pre>
<h2>Deploy it on Hubfly</h2>
<ol>
<li><p>Create a project, open <strong>Create → Compose Stack</strong>, choose the <strong>YAML</strong> tab, paste the complete file from the end of Step 3 and click <strong>Import YAML</strong>.</p>
</li>
<li><p>Check the canvas: 3 services, the <code>pgdata</code> volume, and 2 <code>DEPENDS</code> arrows. The composer's monthly estimate for this stack on the shared tier was <strong>$0.01</strong>.</p>
</li>
<li><p>Click <strong>Deploy Stack</strong> and watch the activity log.</p>
</li>
<li><p>On the <code>api</code> container, open <strong>Network → Add Domain</strong>, pick port <strong>3000</strong>, and create the subdomain.</p>
</li>
<li><p>Open <code>https://&lt;your-subdomain&gt;.eu1.hubfly.app/todos</code>.</p>
</li>
</ol>
<p>You should get this back:</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/a1f076b8-929a-4fd3-8903-c700ccff2725.png" alt="The browser showing the JSON response from /todos" style="display:block;margin:0 auto" />

<pre><code class="language-json">[{"id":1,"task":"ship the migration gate","done":false}]
</code></pre>
<p>A repeat <code>GET /todos</code> from the browser comes back in about 271 ms end to end.</p>
<p>Note: <code>ports: "3000:3000"</code> also creates a public TCP endpoint on <code>tcp.eu1.hubfly.app</code> by itself. That's fine for an API. Never add <code>ports:</code> to <code>db</code>.</p>
<h3>What the 59 seconds looked like</h3>
<table>
<thead>
<tr>
<th>Time (CAT)</th>
<th>Event</th>
</tr>
</thead>
<tbody><tr>
<td>06:32:16</td>
<td>Deploy starts, access validated</td>
</tr>
<tr>
<td>06:32:20</td>
<td>Volume <code>pgdata</code> reused</td>
</tr>
<tr>
<td>06:32:36</td>
<td><code>db</code> deployed (stage 1)</td>
</tr>
<tr>
<td>06:32:38</td>
<td><code>db</code> healthcheck satisfied</td>
</tr>
<tr>
<td>06:32:53</td>
<td><code>migrate</code> deployed (stage 2)</td>
</tr>
<tr>
<td>06:32:55</td>
<td><code>migrate</code> exited 0, gate opens</td>
</tr>
<tr>
<td>06:33:13</td>
<td><code>api</code> deployed (stage 3)</td>
</tr>
<tr>
<td>06:33:14</td>
<td>Stack deployment finished</td>
</tr>
</tbody></table>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/5225a82e-9d62-44e7-8ba6-c730e44c9858.png" alt="The Compose deployment activity log for a successful rollout, stage by stage" style="display:block;margin:0 auto" />

<p>Hubfly marked <code>db</code> healthy about 2 seconds after it deployed, on the failed deploy and the working one alike. So don't rely on the healthcheck alone. The wait loop is what makes the rollout work.</p>
<h2>Why the first deploy failed</h2>
<p>The first version of the file had no wait loop and no TCP healthcheck. Here's what happened.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/3e83521b-203d-47e2-b8b7-24b3e08f3589.png" alt="The first deploy, showing &quot;Deployment failed for job 'migrate'&quot;" style="display:block;margin:0 auto" />

<p><code>db</code> went healthy at 06:28:24. <code>migrate</code> deployed at 06:28:36 and failed 2 seconds later. The API was never created, so the gate did its job. But why did a migration fail against a healthy database?</p>
<p>The job's container was rolled back, taking its logs with it. So the cause can't be proven, but 2 clues from the next deploy point to it.</p>
<p>The migration job's own first line, with the wait loop in place:</p>
<pre><code class="language-text">db-1-7befe5b4.eucentral.hubfly.internal:5432 - no response
waiting for db
db-1-7befe5b4.eucentral.hubfly.internal:5432 - accepting connections
CREATE TABLE
...
</code></pre>
<p>And PostgREST's first 2 seconds of life (container logs are in UTC; the deploy table is CAT, UTC+2):</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/9f54f9a8-6bbe-426f-8507-64218eb6b473.png" alt="PostgREST logs showing &quot;could not translate host name&quot;, then a successful connection" style="display:block;margin:0 auto" />

<pre><code class="language-text">04:33:10 Failed to query the PostgreSQL version ... could not translate host name
         "db-1-7befe5b4.eucentral.hubfly.internal" to address: Name does not resolve
04:33:12 Successfully connected to PostgreSQL 16.15
04:33:14 Schema cache loaded 1 Relations
</code></pre>
<p>On the next deploy, a freshly started container could not resolve the database's private hostname for about 2 seconds. PostgREST shrugs that off because it retries. A one-shot <code>psql</code> doesn't retry. It fails, exits non-zero, and takes your deploy down with it.</p>
<p>That's not a Hubfly quirk you can ignore elsewhere. On any orchestrator, "the dependency is healthy" and "my container can reach it right now" are two different facts. A job that runs once must check the second one itself.</p>
<h2>What can go wrong</h2>
<p><strong>1. The job fails in 2 seconds and you can't see why.</strong> A failed job may be rolled back with its logs. Add the wait loop from Step 2, and make the job print what it's doing (<code>echo "waiting for db"</code>). If it still fails, the next run's logs will show whether it's DNS, the port or the SQL.</p>
<p><strong>2. "Healthy" doesn't mean "reachable".</strong><code>pg_isready</code> without <code>-h</code> checks the Unix socket, and the first-boot setup server listens only there. Use <code>pg_isready -h 127.0.0.1 -U app -d app</code>, and still keep the wait loop in the job: Hubfly marks <code>db</code> healthy about 2 seconds after deploy either way.</p>
<p><strong>3. A failed rollout leaves an old database behind.</strong> After the failed first deploy, the rollback of <code>db</code> itself failed (<code>Rollback failed for 'db'</code>). The old <code>db</code> was left behind, asleep and still attached to the volume. The next deploy then created <code>db-1</code> next to it, on the same <code>pgdata</code> volume.</p>
<img src="https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/10/06e03805-e941-4243-9d5c-6e574894d78d.png" alt="The project view with db (sleeping) and db-1 (running) side by side" style="display:block;margin:0 auto" />

<p>Two Postgres servers on one data directory is how you lose data. Before you redeploy after a failure, open the project, and stop or remove any leftover database container.</p>
<p><strong>4. The migration isn't idempotent.</strong> The job runs on every deploy. <code>CREATE TABLE</code> without <code>IF NOT EXISTS</code> will fail on deploy number 2 and block every release after it. Guard every statement, or use a migration tool that records what it applied (Prisma's <code>migrate deploy</code>, Flyway, golang-migrate, Alembic).</p>
<h2>Takeaway</h2>
<p><code>service_completed_successfully</code> is a good gate. It only works if the job behind it is honest: it waits for the database itself, stops on the first error, and is safe to run again. Put those 3 properties in your migration job and a broken schema stops the release instead of reaching users.</p>
<h2>Deploy this</h2>
<p>Paste the Compose file into a new Hubfly project (<strong>Create → Compose Stack → YAML</strong>) and click <strong>Deploy Stack</strong>. The shared tier needs no card. Change <code>change-me</code> first.</p>
<h3>What it costs</h3>
<p>The composer estimated \(0.01/month for this stack on the shared tier. That matches a 1 GB volume: 1 GB × \)0.00001536 per GB-hour × 730 h = $0.011 (the free tier includes 1 GB of storage).</p>
<p>Shared containers auto-sleep, and their compute is free within the free tier's 360 hours a month. Two containers kept awake all month would use 1,460 hours.</p>
<p>If you keep db and api awake using a dedicated container (0.5 vCPU, 256 MB):</p>
<p>CPU: 0.5 = $1.46</p>
<p>RAM: 256 MB = $0.36</p>
<p>Per container: \(1.94/month; for db + api: \)3.88/month, plus $0.11 of storage</p>
<p>The migration job runs for a couple of seconds per deploy, which rounds to $0.00.</p>
<hr />
<p><strong>Read next:</strong></p>
<ul>
<li><p><a href="https://hubfly.space/docs/compose">Compose Stacks on Hubfly</a>: dependency conditions and <code>{{service:...}}</code> references</p>
</li>
<li><p><a href="https://hubfly.space/docs/compose-json-reference">ComposeSpec JSON reference</a>: jobs, stages and restart policies</p>
</li>
<li><p><a href="https://hubfly.space/docs">Hubfly documentation home</a>: volumes, networking and private DNS</p>
</li>
<li><p><a href="https://blog.hubfly.space/how-to-deploy-odoo-on-hubfly-space-with-postgresql-and-persistent-storage?utm_source=hashnode&amp;utm_campaign=docker-compose-migration-job-service-completed-successfully">Deploy Odoo on Hubfly with PostgreSQL and persistent storage</a>: another Postgres stack with a volume</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>