<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pavel</title>
    <description>The latest articles on DEV Community by Pavel (@pavel-hostim).</description>
    <link>https://dev.to/pavel-hostim</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3411990%2Fe76abcc2-c7fa-4249-925b-8bf480881642.jpeg</url>
      <title>DEV Community: Pavel</title>
      <link>https://dev.to/pavel-hostim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pavel-hostim"/>
    <language>en</language>
    <item>
      <title>Migrating a Production Cluster Off ingress-nginx to Traefik v3</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:43:32 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/migrating-a-production-cluster-off-ingress-nginx-to-traefik-v3-19ij</link>
      <guid>https://dev.to/pavel-hostim/migrating-a-production-cluster-off-ingress-nginx-to-traefik-v3-19ij</guid>
      <description>&lt;p&gt;Short answer: Traefik v3.7's &lt;code&gt;kubernetesIngressNGINX&lt;/code&gt; provider reads Ingresses on the ingress-nginx class and translates its annotations, so the cutover itself is a load balancer IP handover — no annotation rewrite across your Ingresses, no dual-IngressClass trick. What it does not cover is the ingress-nginx &lt;em&gt;controller&lt;/em&gt; configuration: TLS redirect, global error pages, the default certificate, auth annotations after you switch class. Those you port by hand.&lt;/p&gt;

&lt;p&gt;We moved a production cluster on 23 August 2026: many Ingresses, multi-tenant, customer apps. Here is what we did and what it cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why we moved, and why we waited
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/kubernetes/ingress-nginx" rel="noopener noreferrer"&gt;ingress-nginx&lt;/a&gt; is retired upstream and has not received patches since March 2026. Running an unpatched component in the request path of every customer app is not a position you can hold indefinitely, so the move was going to happen.&lt;/p&gt;

&lt;p&gt;We waited about six months anyway. Being early on a migration like this means finding the bugs yourself and writing the guide instead of reading it. There was no upside to going first: ingress-nginx kept working the whole time, and every month of waiting meant more people had hit the sharp edges before us and written them down. That trade only stops making sense once the security exposure outweighs it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Traefik
&lt;/h2&gt;

&lt;p&gt;Our general rule is to stay close to Kubernetes best practice rather than pick something clever. If upstream converges fully on Gateway API, we want to follow without another migration.&lt;/p&gt;

&lt;p&gt;That is most of the reasoning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mature.&lt;/strong&gt; Long track record as an ingress controller, not a recent entrant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supports both Ingress and Gateway API.&lt;/strong&gt; We run Ingress today. The option to move to Gateway API later without changing controllers again is the point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ingress-nginx compatibility provider was a bonus we found while planning, not the reason for the choice. It did make the cutover much cheaper than it would otherwise have been.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the cutover worked
&lt;/h2&gt;

&lt;p&gt;Traefik v3.7 added &lt;code&gt;providers.kubernetesIngressNGINX&lt;/code&gt;. It serves Ingresses whose IngressClass object names the ingress-nginx controller, and translates the &lt;code&gt;nginx.ingress.kubernetes.io/*&lt;/code&gt; annotations itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kubernetesIngressNGINX&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;controllerClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k8s.io/ingress-nginx"&lt;/span&gt;
    &lt;span class="na"&gt;ingressClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nginx"&lt;/span&gt;
    &lt;span class="na"&gt;defaultBackendService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik/default-backend"&lt;/span&gt;
    &lt;span class="na"&gt;publishService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that enabled, every existing Ingress — including the ones our operator generates for customer apps and cert-manager's HTTP-01 solver Ingresses — needed no edit. Deploy Traefik alongside ingress-nginx, then move the MetalLB load balancer IP from one Service to the other. That is the cutover.&lt;/p&gt;

&lt;p&gt;It does not overlap with the normal &lt;code&gt;kubernetesIngress&lt;/code&gt; provider: that one matches only IngressClass objects naming Traefik's controller, this one only &lt;code&gt;k8s.io/ingress-nginx&lt;/code&gt;. We left it enabled after the migration. Turning it off means editing every Ingress in the cluster to buy nothing, and it keeps a rollback cheap.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we ported by hand
&lt;/h2&gt;

&lt;p&gt;The compatibility provider covers Ingress annotations. It does not cover what was configured on the ingress-nginx controller itself, and it stops applying to any Ingress the moment you switch that Ingress to &lt;code&gt;ingressClassName: traefik&lt;/code&gt;. Four things needed doing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic auth on platform endpoints
&lt;/h3&gt;

&lt;p&gt;Our internal services were protected with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;nginx.ingress.kubernetes.io/auth-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;basic&lt;/span&gt;
&lt;span class="na"&gt;nginx.ingress.kubernetes.io/auth-secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal-basic-auth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traefik's native provider does not implement these annotations. It does not error and it does not fall back — the router is created without authentication. So the Middleware has to exist before the class flips, not after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal-basic-auth&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;basicAuth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal-basic-auth-traefik&lt;/span&gt;
    &lt;span class="na"&gt;realm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authentication&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Required"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;referenced from the Ingress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik.ingress.kubernetes.io/router.middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internal-internal-basic-auth@kubernetescrd"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details. Traefik reads the key &lt;code&gt;users&lt;/code&gt;, ingress-nginx reads &lt;code&gt;auth&lt;/code&gt;. And Traefik's BasicAuth rejects a Secret with more than one key (&lt;code&gt;must be single element exactly&lt;/code&gt;), so you cannot just add a second key to the existing Secret — we created a separate &lt;code&gt;-traefik&lt;/code&gt; Secret and left the original intact for rollback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Global error pages
&lt;/h3&gt;

&lt;p&gt;ingress-nginx has &lt;code&gt;custom-http-errors&lt;/code&gt; in the controller ConfigMap. Traefik has no global equivalent: errors are a Middleware, and the compatibility provider only honours the per-Ingress annotation. We serve a branded page for stopped, broken or suspended apps, and we are not going to add an annotation to every Ingress the operator generates, so the Middleware hangs off the entrypoint instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;errorpage&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;503"&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default-backend&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik-errorpage@kubernetescrd&lt;/span&gt;
  &lt;span class="na"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik-errorpage@kubernetescrd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This needs &lt;code&gt;providers.kubernetesCRD.allowEmptyServices: true&lt;/code&gt;. Without it, a Service with no endpoints returns 404 rather than 503, and the Middleware never fires.&lt;/p&gt;

&lt;p&gt;Unknown hostnames are a separate mechanism: &lt;code&gt;defaultBackendService&lt;/code&gt; on the compatibility provider, replacing ingress-nginx's &lt;code&gt;defaultBackend&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The default certificate
&lt;/h3&gt;

&lt;p&gt;ingress-nginx serves a fallback certificate via &lt;code&gt;--default-ssl-certificate&lt;/code&gt;. We rely on it: the operator leaves the built-in app domains out of the Ingress TLS block on purpose, because a wildcard covers them.&lt;/p&gt;

&lt;p&gt;In Traefik this is a &lt;code&gt;TLSStore&lt;/code&gt; named &lt;code&gt;default&lt;/code&gt;, and the Secret must live in Traefik's namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tlsStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;defaultCertificate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss it and those hostnames are served Traefik's own self-signed certificate. Nothing in the control plane reports an error; the failure is a browser warning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics and dashboards
&lt;/h3&gt;

&lt;p&gt;Metric names change completely: &lt;code&gt;nginx_ingress_controller_*&lt;/code&gt; becomes &lt;code&gt;traefik_service_*&lt;/code&gt; and &lt;code&gt;traefik_entrypoint_*&lt;/code&gt;. Traefik's ServiceMonitor relabels its own &lt;code&gt;service&lt;/code&gt; label to &lt;code&gt;exported_service&lt;/code&gt;, so panels grouping by service have to group on that instead. Our networking dashboard was empty until it was rewritten — worth knowing in advance so nobody reads blank panels as an outage.&lt;/p&gt;

&lt;p&gt;One setting we deliberately did not port: &lt;code&gt;proxy-body-size: 50m&lt;/code&gt;. Traefik does not limit request body size by default.&lt;/p&gt;




&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;ingress-nginx redirects HTTP to HTTPS by default. Traefik does not, and nothing in the cutover carried that behaviour across. Every app in the cluster answered on port 80 in cleartext. Nothing was down, no health check failed, and traffic on 443 looked normal — plain HTTP was simply being served instead of redirected.&lt;/p&gt;

&lt;p&gt;Our monitoring caught it, because it checks for the redirect. The fix belongs at the entrypoint rather than per Ingress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;redirections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;entryPoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;websecure&lt;/span&gt;
          &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
          &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not break certificate issuance — both Let's Encrypt and cert-manager 1.21 follow the redirect during HTTP-01 without validating the certificate at the target.&lt;/p&gt;

&lt;p&gt;The general lesson is not about Traefik. Controller-level defaults are invisible in your manifests, so a migration diff of your Ingresses shows nothing missing. What ingress-nginx did &lt;em&gt;for&lt;/em&gt; you by default is exactly what nobody wrote down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Uninstalling ingress-nginx
&lt;/h2&gt;

&lt;p&gt;Do it through Helm, before removing the namespace.&lt;/p&gt;

&lt;p&gt;Its admission webhook is cluster-scoped with &lt;code&gt;failurePolicy: Fail&lt;/code&gt; on every Ingress CREATE and UPDATE. If you delete the namespace and orphan the webhook, every Ingress and certificate change in the cluster stops working, cert-manager's included. The chart removes the webhook in the right order. &lt;code&gt;kubectl delete ns&lt;/code&gt; does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;p&gt;Before switching any IngressClass to &lt;code&gt;traefik&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Auth annotations on that Ingress → Middleware created first, same namespace, single-key &lt;code&gt;users&lt;/code&gt; Secret.&lt;/li&gt;
&lt;li&gt;TLS redirect → entrypoint redirection configured.&lt;/li&gt;
&lt;li&gt;Custom error pages → entrypoint Middleware plus &lt;code&gt;allowEmptyServices: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Default certificate → &lt;code&gt;TLSStore/default&lt;/code&gt;, Secret in Traefik's namespace.&lt;/li&gt;
&lt;li&gt;Dashboards and alerts on &lt;code&gt;nginx_ingress_controller_*&lt;/code&gt; → rewritten.&lt;/li&gt;
&lt;li&gt;Body size and timeout tuning from the nginx ConfigMap → re-applied if you need it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After the cutover, check the redirect and the auth explicitly rather than trusting a green rollout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://app.example.com/ | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;                       &lt;span class="c"&gt;# expect 308&lt;/span&gt;
curl &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://internal.example.com/  &lt;span class="c"&gt;# expect 401&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And confirm the certificate served on a domain that has no TLS block of its own is your wildcard, not the controller's self-signed default.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is ingress-nginx dead?&lt;/strong&gt;&lt;br&gt;
Retired upstream, unpatched since March 2026. It keeps running; security fixes are not coming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I have to rewrite my nginx annotations to move to Traefik?&lt;/strong&gt;&lt;br&gt;
No. Traefik v3.7+ ships &lt;code&gt;providers.kubernetesIngressNGINX&lt;/code&gt;, which serves Ingresses on the ingress-nginx class and translates its annotations. Controller-level configuration is not covered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What breaks when I switch an Ingress from class nginx to class traefik?&lt;/strong&gt;&lt;br&gt;
Anything relying on an annotation the native Traefik provider does not implement. Auth annotations are the one to check first: the endpoint is served without authentication rather than erroring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does an HTTP-to-HTTPS redirect break Let's Encrypt HTTP-01?&lt;/strong&gt;&lt;br&gt;
No. Let's Encrypt and cert-manager follow the redirect and do not validate the certificate at the target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I uninstall ingress-nginx safely?&lt;/strong&gt;&lt;br&gt;
Through Helm, before deleting the namespace. Its cluster-scoped admission webhook has &lt;code&gt;failurePolicy: Fail&lt;/code&gt;, so orphaning it blocks every Ingress change in the cluster.&lt;/p&gt;




&lt;p&gt;If you would rather not run any of this yourself, that is the point of &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim&lt;/a&gt; — you push a container, we run the ingress, the certificates and the cluster underneath. EU-hosted, flat monthly price.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Deploy an app on Hostim, free 5-day trial, no card&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We're building Hostim.dev to make this simpler, and we are happy to answer any questions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Last updated: 31 August 2026. Versions: Traefik chart 41.3.0 (v3.7.11), cert-manager 1.21. Configuration snippets are from our production cluster, trimmed for readability. Check the &lt;a href="https://doc.traefik.io/traefik/" rel="noopener noreferrer"&gt;Traefik documentation&lt;/a&gt; for the current list of translated ingress-nginx annotations before planning your own cutover.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>traefik</category>
      <category>devops</category>
      <category>sre</category>
    </item>
    <item>
      <title>What Is beacon.min.js? Cloudflare's Auto-Injected Analytics, and How to Turn It Off (2026)</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Mon, 17 Aug 2026 11:07:20 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/what-is-beaconminjs-cloudflares-auto-injected-analytics-and-how-to-turn-it-off-2026-10g2</link>
      <guid>https://dev.to/pavel-hostim/what-is-beaconminjs-cloudflares-auto-injected-analytics-and-how-to-turn-it-off-2026-10g2</guid>
      <description>&lt;p&gt;Short answer: &lt;code&gt;beacon.min.js&lt;/code&gt; is Cloudflare's Real User Measurement script. If your domain is on a &lt;strong&gt;free&lt;/strong&gt; Cloudflare plan and proxied through them (the orange cloud), Cloudflare has been &lt;strong&gt;injecting it into your HTML by default since September 2025&lt;/strong&gt; — you did not add it, it is not in your repo, and it arrives before your page reaches the browser. Paid plans are opt-in only. You turn it off in &lt;strong&gt;Analytics &amp;amp; Logs → Web Analytics → Manage Site → Advanced Options → JS Snippet injection&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two things are worth separating here, because the thread that put this on the front page of Hacker News this week mixed them together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The GDPR panic is mostly overstated.&lt;/strong&gt; The default configuration excludes EU visitor data. Cloudflare's own words, from the thread: "we will not collect any RUM metrics from traffic that passes through our European and UK data centers."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The consent problem is real anyway, and it is a different problem.&lt;/strong&gt; The beacon is injected at the edge, so it lands on the page before your consent management platform gets to run. There is nothing for your CMP to gate. That is true regardless of who the visitor is.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I run a hosting company, so treat me accordingly — I have an obvious interest in you thinking hard about what your platform does to your bytes. That is exactly why I want to be careful with the facts rather than loud about them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;Someone moved their nameservers to Cloudflare and later found a 31KB JavaScript file executing on a site they had deliberately built with no JavaScript at all — which sounds like a compromise until you read the docs and realise it is just what the free plan does.&lt;/p&gt;

&lt;p&gt;The mechanism matters more than the outrage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Injection happens &lt;strong&gt;at the edge&lt;/strong&gt;, in Cloudflare's proxy, on the way to the visitor. Your origin server sends HTML without the script; the visitor receives HTML with it.&lt;/li&gt;
&lt;li&gt;It only applies to &lt;strong&gt;proxied&lt;/strong&gt; records — orange cloud. DNS-only records pass through untouched.&lt;/li&gt;
&lt;li&gt;With automatic setup and no Rules configured, it goes on &lt;strong&gt;every page of every subdomain in the zone&lt;/strong&gt;, not just the pages you were thinking about.&lt;/li&gt;
&lt;li&gt;When auto-injected on a proxied domain, the beacon reports to &lt;code&gt;/cdn-cgi/rum&lt;/code&gt; &lt;strong&gt;on your own domain&lt;/strong&gt;, so it does not show up as a third-party request in the obvious place you would look for one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last detail is the one I would have missed. A quick scan of third-party domains in your network tab does not catch it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is Cloudflare's beacon a GDPR problem?
&lt;/h2&gt;

&lt;p&gt;Probably not in the default configuration, and I would rather say that plainly than farm the fear.&lt;/p&gt;

&lt;p&gt;Here is what Cloudflare stated in the discussion, verbatim: &lt;em&gt;"The version of Web Analytics that will be enabled by default excludes data from EU visitors (this can be changed in the dashboard if you want)."&lt;/em&gt; And: &lt;em&gt;"you can choose to drop requests from European and UK visitors if you so desire... we will not collect any RUM metrics from traffic that passes through our European and UK data centers."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So if you are an EU business serving mostly EU visitors, the default posture is that the measurement is not collected for them. That is a genuinely more thoughtful default than "collect everything and let the customer sort it out."&lt;/p&gt;

&lt;p&gt;What that statement does &lt;strong&gt;not&lt;/strong&gt; settle — and what I could not confirm from Cloudflare's public documentation either way — is whether the script is still &lt;em&gt;served and executed&lt;/em&gt; in EU visitors' browsers while the metrics are discarded on Cloudflare's side. "We do not collect" and "we do not inject" are different promises, and only one of them was made. If that distinction matters for your compliance posture, check it on your own domain rather than taking my word or theirs. The command is in the next section.&lt;/p&gt;

&lt;p&gt;The consent point stands separately from all of this. Edge injection cannot be gated by a consent banner that has not loaded yet, so any tag-governance story you tell your auditors has a hole in it while automatic injection is on.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to check what your host injects
&lt;/h2&gt;

&lt;p&gt;This is the part worth keeping regardless of which platform you are on. Ask your origin for the page, then ask the public internet for the same page, and compare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. What your server actually sends (replace with your origin IP)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; example.com:443:203.0.113.10 https://example.com/ &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; origin.html

&lt;span class="c"&gt;# 2. What a visitor actually receives&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/ &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; edge.html

&lt;span class="c"&gt;# 3. Anything your origin never wrote&lt;/span&gt;
diff origin.html edge.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the specific case, a direct grep is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'cloudflareinsights|beacon\.min\.js|/cdn-cgi/rum'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output means nothing is being injected on that path. Any hit means something is editing your HTML between your server and your users.&lt;/p&gt;

&lt;p&gt;Worth running against a few different paths, not just &lt;code&gt;/&lt;/code&gt;. Rules can scope injection to a subset of pages, so the homepage being clean does not prove much on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Turning it off
&lt;/h2&gt;

&lt;p&gt;In the Cloudflare dashboard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Analytics &amp;amp; Logs → Web Analytics&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manage Site&lt;/strong&gt; on the domain&lt;/li&gt;
&lt;li&gt;Expand &lt;strong&gt;Advanced Options&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Toggle off &lt;strong&gt;JS Snippet injection&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you actually want the performance data — and RUM data is genuinely useful, that part of Cloudflare's argument is fine — disable the automatic injection and add the snippet yourself. Then it is a tag like any other tag: your CMP can gate it, your CSP applies to it, and it is visible in your repository where the next person can find it.&lt;/p&gt;

&lt;p&gt;One quirk to know before you go looking: several people reported having to enable Web Analytics first in order to reach the switch that turns the injection off.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Cloudflare collects, and for how long
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Script&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;beacon.min.js&lt;/code&gt;, roughly 31KB, from &lt;code&gt;static.cloudflareinsights.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reporting endpoint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/cdn-cgi/rum&lt;/code&gt; on your own domain when auto-injected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default on&lt;/td&gt;
&lt;td&gt;Free plans, since September 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default on paid plans&lt;/td&gt;
&lt;td&gt;No — opt-in only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU/UK visitors&lt;/td&gt;
&lt;td&gt;Excluded from collection in the default configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw data retention&lt;/td&gt;
&lt;td&gt;Unsampled for 7 days, then aggregated down to roughly 10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope with automatic setup&lt;/td&gt;
&lt;td&gt;Every page and subdomain in the zone, unless Rules narrow it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The part I actually care about
&lt;/h2&gt;

&lt;p&gt;The reason this landed on the front page is not that RUM is sinister. It is that the boundary moved without anyone noticing, and a free plan turned out to include a term nobody read.&lt;/p&gt;

&lt;p&gt;It rhymes with the other platform stories I keep writing about — &lt;a href="https://hostim.dev/blog/netlify-credit-pricing" rel="noopener noreferrer"&gt;Netlify moving to credit pricing&lt;/a&gt;, or &lt;a href="https://hostim.dev/blog/usage-based-pricing-creep" rel="noopener noreferrer"&gt;metered bills that drift upward month after month&lt;/a&gt; — in the sense that the platform changes something in the middle of your stack, the change turns out to be perfectly defensible once you go and read the docs, and you find out about it later than you would have liked. The free tier is usually where it lands first, because that is where the leverage is and where nobody is reading a contract.&lt;/p&gt;

&lt;p&gt;I do not think Cloudflare did anything villainous here. Their EU default is more careful than most, and their engineer showed up in the thread and answered directly, which is more than most companies manage. But "on by default for the people who are not paying, opt-in for the people who are" is a choice, and it is worth seeing it clearly rather than being annoyed at it vaguely.&lt;/p&gt;

&lt;p&gt;Since it would be cowardly to write this without stating our own position: Hostim does not modify customer response bodies. The ingress in front of your app sets one annotation, &lt;code&gt;x-forwarded-proto: https&lt;/code&gt;, which is a request header going towards your container — no &lt;code&gt;sub_filter&lt;/code&gt;, no snippet injection, nothing added to what you send back. You do not have to take my word for it, and you should not: run the same &lt;code&gt;diff&lt;/code&gt; from the section above against your own Hostim app. If it ever comes back non-empty, that is a bug and I want to hear about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is beacon.min.js?&lt;/strong&gt;&lt;br&gt;
It is Cloudflare's Real User Measurement (RUM) script, about 31KB, served from &lt;code&gt;static.cloudflareinsights.com&lt;/code&gt;. It measures page performance in real visitors' browsers and reports back to Cloudflare.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is beacon.min.js on my site when I never added it?&lt;/strong&gt;&lt;br&gt;
Because Cloudflare injects it at the edge. Free plans have this on by default since September 2025 for domains proxied through Cloudflare (orange cloud). It never touches your source code, so it will not appear in your repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I disable Cloudflare's automatic beacon injection?&lt;/strong&gt;&lt;br&gt;
Cloudflare dashboard → Analytics &amp;amp; Logs → Web Analytics → Manage Site → Advanced Options → toggle off &lt;strong&gt;JS Snippet injection&lt;/strong&gt;. You may need to enable Web Analytics first before the toggle is reachable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the Cloudflare beacon violate GDPR?&lt;/strong&gt;&lt;br&gt;
In the default configuration Cloudflare states it does not collect RUM metrics from traffic through its European and UK data centres, so the collection concern is largely addressed. The separate, unresolved issue is consent management: because the script is injected at the edge, it loads before your consent platform can gate it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Cloudflare Web Analytics injected on paid plans too?&lt;/strong&gt;&lt;br&gt;
No. Cloudflare states all paid plans are opt-in only. The default-on behaviour applies to free plans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I see what my hosting provider injects into my pages?&lt;/strong&gt;&lt;br&gt;
Fetch the page from your origin and from the public URL, then diff the two. Anything present in the second and absent in the first was added in transit. There is a copy-paste version of this above.&lt;/p&gt;




&lt;p&gt;If you would rather your hosting provider just serve what you gave it, that is roughly the whole design philosophy behind &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim&lt;/a&gt; — your container, your bytes, a flat monthly price, hosted in the EU. New projects get a free 5-day trial, no card.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://hostim.dev/dashboard?preview=1&amp;amp;modal=1&amp;amp;compose=1" rel="noopener noreferrer"&gt;Deploy an app on Hostim, free 5-day trial, no card&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We're building Hostim.dev to make this simpler, and we are happy to answer any questions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Last updated: 17 August 2026. Cloudflare's quoted statements are from its engineer's replies in the &lt;a href="https://news.ycombinator.com/item?id=49322107" rel="noopener noreferrer"&gt;Hacker News discussion&lt;/a&gt; on 16 August 2026; the beacon endpoint, sampling and retention details are from &lt;a href="https://developers.cloudflare.com/web-analytics/faq/" rel="noopener noreferrer"&gt;Cloudflare's Web Analytics documentation&lt;/a&gt;, checked 17 August 2026. Defaults change — verify against your own dashboard before relying on any of this.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hostim.dev/blog/cloudflare-beacon-injection/" rel="noopener noreferrer"&gt;hostim.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>privacy</category>
      <category>devops</category>
    </item>
    <item>
      <title>What Does 'Hello World' Actually Cost on AWS vs Bare Metal? (2026)</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Mon, 03 Aug 2026 15:39:51 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/what-does-hello-world-actually-cost-on-aws-vs-bare-metal-2026-4f41</link>
      <guid>https://dev.to/pavel-hostim/what-does-hello-world-actually-cost-on-aws-vs-bare-metal-2026-4f41</guid>
      <description>&lt;p&gt;Short answer: one "hello world" container with a domain and HTTPS costs about &lt;strong&gt;$32 per month on AWS if you set it up carefully&lt;/strong&gt;, and about &lt;strong&gt;$69 per month if you follow the default AWS tutorial&lt;/strong&gt;. The same thing on a small Hetzner Cloud server costs &lt;strong&gt;€5.99 per month&lt;/strong&gt;. Bare metal is the surprising one. A Hetzner AX42-1 costs &lt;strong&gt;€97.30 per month&lt;/strong&gt;, which makes it the most expensive option here, not the cheapest.&lt;/p&gt;

&lt;p&gt;Two results are worth explaining, and neither is what people usually say:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compute is not the expensive part on AWS at this size.&lt;/strong&gt; In the realistic setup, the container is 15% of the bill. The other 85% is the load balancer and the NAT gateway. Those cost the same whether you serve one request per month or a million.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bare metal is not cheap. It is cheap per app.&lt;/strong&gt; A dedicated server only pays off when you fill it. For one container it is a bad deal.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How I priced this
&lt;/h2&gt;

&lt;p&gt;Here is the method first. Every rate below links to the vendor price list, and the arithmetic is shown, so you can check any line yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workload:&lt;/strong&gt; one container running a web app, one replica, reachable on your own domain over HTTPS. No database, no background jobs, almost no traffic. This is the thing you deploy on day one to check that the pipeline works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I measured:&lt;/strong&gt; list price for the simplest working setup on each platform, in Europe, for one month (730 hours). Not performance. Not a tuned setup, and not a setup with commitments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I left out:&lt;/strong&gt; VAT, CloudWatch logs, your own time, and discounts from AWS Savings Plans. Savings Plans do lower the Fargate line. They do not apply to the Application Load Balancer or the NAT gateway, and that is where most of the AWS money goes here. A commitment lowers the small number, not the big one.&lt;/p&gt;

&lt;p&gt;Rates are AWS &lt;code&gt;eu-central-1&lt;/code&gt; (Frankfurt) and Hetzner Falkenstein/Helsinki, as published on &lt;strong&gt;3 August 2026&lt;/strong&gt;. EUR to USD at &lt;strong&gt;1.1535&lt;/strong&gt; (&lt;a href="https://api.frankfurter.dev/v1/latest?base=EUR&amp;amp;symbols=USD" rel="noopener noreferrer"&gt;ECB reference rate, 3 August 2026&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What does one container cost on AWS Fargate?
&lt;/h2&gt;

&lt;p&gt;Here are two versions. The difference between them is the interesting part.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cheapest AWS setup that still works
&lt;/h3&gt;

&lt;p&gt;Fargate on ARM. The task sits in a public subnet with its own public IP, so there is no NAT gateway.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line item&lt;/th&gt;
&lt;th&gt;Arithmetic&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fargate ARM, 0.25 vCPU&lt;/td&gt;
&lt;td&gt;0.25 × $0.03725 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$6.80&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fargate ARM, 0.5 GB RAM&lt;/td&gt;
&lt;td&gt;0.5 × $0.00409 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$1.49&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application Load Balancer, base&lt;/td&gt;
&lt;td&gt;$0.027 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$19.71&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public IPv4 address&lt;/td&gt;
&lt;td&gt;$0.005 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$3.65&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route 53 hosted zone&lt;/td&gt;
&lt;td&gt;flat&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.50&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data transfer out&lt;/td&gt;
&lt;td&gt;under the 100 GB free allowance&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ECR image storage&lt;/td&gt;
&lt;td&gt;under the 500 MB free tier&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$32.15&lt;/strong&gt; (about €27.87)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The container is $8.29 of that total. The load balancer alone costs more than twice as much as the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  The setup you get from the tutorials
&lt;/h3&gt;

&lt;p&gt;Fargate on x86. The task sits in a private subnet behind a NAT gateway, which is what the CDK and the console give you by default. I also added one load balancer capacity unit for real traffic.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line item&lt;/th&gt;
&lt;th&gt;Arithmetic&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fargate x86, 0.25 vCPU&lt;/td&gt;
&lt;td&gt;0.25 × $0.04656 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$8.50&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fargate x86, 0.5 GB RAM&lt;/td&gt;
&lt;td&gt;0.5 × $0.00511 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$1.87&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application Load Balancer, base&lt;/td&gt;
&lt;td&gt;$0.027 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$19.71&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB capacity units&lt;/td&gt;
&lt;td&gt;1 LCU × $0.008 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$5.84&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT gateway, hourly&lt;/td&gt;
&lt;td&gt;$0.045 × 730&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$32.85&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT gateway, data processing&lt;/td&gt;
&lt;td&gt;5 GB × $0.045&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.23&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route 53 hosted zone&lt;/td&gt;
&lt;td&gt;flat&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.50&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$69.50&lt;/strong&gt; (about €60.25)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The NAT gateway costs more than everything else together&lt;/strong&gt;, including the app you want to run. Its job is to let your container reach the internet from a private subnet. For a hello world you get very little back for that money, and it is switched on by default in most getting-started guides.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/eu-central-1/index.json" rel="noopener noreferrer"&gt;AWS Price List API, AmazonECS eu-central-1&lt;/a&gt; (published 7 July 2026), &lt;a href="https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AWSELB/current/eu-central-1/index.json" rel="noopener noreferrer"&gt;AWSELB eu-central-1&lt;/a&gt; (20 July 2026), &lt;a href="https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonVPC/current/eu-central-1/index.json" rel="noopener noreferrer"&gt;AmazonVPC eu-central-1&lt;/a&gt; (24 July 2026), &lt;a href="https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonRoute53/current/index.json" rel="noopener noreferrer"&gt;Route 53&lt;/a&gt; (27 May 2026), &lt;a href="https://aws.amazon.com/vpc/pricing/" rel="noopener noreferrer"&gt;VPC pricing&lt;/a&gt;, &lt;a href="https://aws.amazon.com/ecr/pricing/" rel="noopener noreferrer"&gt;ECR pricing&lt;/a&gt;, &lt;a href="https://aws.amazon.com/ec2/pricing/on-demand/" rel="noopener noreferrer"&gt;EC2 on-demand pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the same container cost on a VPS?
&lt;/h2&gt;

&lt;p&gt;A Hetzner Cloud CX23 has 2 vCPU, 4 GB RAM, 40 GB disk and 20 TB of included traffic. You run Docker on it, plus Caddy for automatic TLS.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line item&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CX23 instance&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€5.49&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary IPv4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€0.50&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;€5.99&lt;/strong&gt; (about $6.91)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is about one tenth of the realistic AWS bill, on a machine with 8 times the RAM and 8 times the vCPU count of the Fargate task. In exchange you lose managed TLS renewal, health checks and rolling deploys. You now do those yourself. For one hello world that is maybe an afternoon of Caddy config and a systemd unit.&lt;/p&gt;

&lt;p&gt;These prices changed recently. Hetzner raised prices on &lt;strong&gt;15 June 2026&lt;/strong&gt;, and the increases were very uneven:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Old&lt;/th&gt;
&lt;th&gt;New&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CX23&lt;/td&gt;
&lt;td&gt;€3.99&lt;/td&gt;
&lt;td&gt;€5.49&lt;/td&gt;
&lt;td&gt;+38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CAX11 (ARM)&lt;/td&gt;
&lt;td&gt;€4.49&lt;/td&gt;
&lt;td&gt;€5.99&lt;/td&gt;
&lt;td&gt;+33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPX22&lt;/td&gt;
&lt;td&gt;€7.99&lt;/td&gt;
&lt;td&gt;€19.49&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+144%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CCX13 (dedicated vCPU)&lt;/td&gt;
&lt;td&gt;€15.99&lt;/td&gt;
&lt;td&gt;€42.99&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+169%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cheap shared-vCPU plans went up a little. The dedicated-vCPU plans almost tripled. If you remember Hetzner prices from before June, check the specific plan again before you budget with it. Sources: &lt;a href="https://docs.hetzner.com/general/infrastructure-and-availability/price-adjustment/" rel="noopener noreferrer"&gt;Hetzner price adjustment notice&lt;/a&gt; (last changed 8 July 2026) and &lt;a href="https://docs.hetzner.com/general/others/ipv4-pricing/" rel="noopener noreferrer"&gt;Hetzner IPv4 pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is bare metal cheaper than AWS?
&lt;/h2&gt;

&lt;p&gt;For one container, no.&lt;/p&gt;

&lt;p&gt;A Hetzner AX42-1 dedicated server costs &lt;strong&gt;€97.30 per month plus a €49 one-time setup fee&lt;/strong&gt;, without IPv4 (&lt;a href="https://docs.hetzner.com/general/infrastructure-and-availability/price-adjustment/" rel="noopener noreferrer"&gt;same price list&lt;/a&gt;). That is €112.24 in the first month and €97.30 after that. It costs more than the $69.50 AWS bill it is supposed to beat.&lt;/p&gt;

&lt;p&gt;Bare metal wins on density, not on price. The arithmetic is simple. At €97.30 against €5.99, a dedicated server has to replace about &lt;strong&gt;16 small cloud instances&lt;/strong&gt; before it breaks even. Below that number you pay for capacity you do not use.&lt;/p&gt;

&lt;p&gt;So the rule is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 app:&lt;/strong&gt; use a VPS, or a platform that bills per app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A few apps:&lt;/strong&gt; still a VPS, maybe a bigger one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dozens of apps, or heavy steady load:&lt;/strong&gt; now bare metal is the cheap option, and the saving is large.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If someone tells you bare metal is cheaper without asking how many workloads you run, the number they give you is not useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;th&gt;In USD&lt;/th&gt;
&lt;th&gt;What you manage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Fargate, cheapest working setup&lt;/td&gt;
&lt;td&gt;€27.87&lt;/td&gt;
&lt;td&gt;$32.15&lt;/td&gt;
&lt;td&gt;IAM, task definitions, ALB, DNS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Fargate, default tutorial setup&lt;/td&gt;
&lt;td&gt;€60.25&lt;/td&gt;
&lt;td&gt;$69.50&lt;/td&gt;
&lt;td&gt;the same, plus a NAT gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner CX23 + IPv4&lt;/td&gt;
&lt;td&gt;€5.99&lt;/td&gt;
&lt;td&gt;$6.91&lt;/td&gt;
&lt;td&gt;the whole machine: OS, TLS, deploys, patching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner AX42-1 bare metal&lt;/td&gt;
&lt;td&gt;€97.30 (+€49 setup)&lt;/td&gt;
&lt;td&gt;$112.24 (+$56.53)&lt;/td&gt;
&lt;td&gt;the whole machine, plus hardware failure planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim&lt;/a&gt; sa-1-1&lt;/td&gt;
&lt;td&gt;€2.50&lt;/td&gt;
&lt;td&gt;$2.88&lt;/td&gt;
&lt;td&gt;your container&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All prices exclude VAT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the AWS bill shaped like this?
&lt;/h2&gt;

&lt;p&gt;Because AWS sells you separate parts, and a working web app needs about six of them. Each part is cheap on its own and does a sensible job. The load balancer is a good load balancer. The NAT gateway solves a real problem. But hello world needs all six, and the five that are not compute do not get cheaper when your traffic is zero.&lt;/p&gt;

&lt;p&gt;That is the real lesson, and it applies no matter who you buy from. &lt;strong&gt;At small scale you are not paying for capacity. You are paying for fixed infrastructure.&lt;/strong&gt; To make a small deployment cheap, do not buy that infrastructure separately. Either put it all on one machine yourself, or use a platform that spreads the cost across all its customers.&lt;/p&gt;

&lt;p&gt;It also means the common advice to pick a smaller instance does not help here. Halving the Fargate task saves $4. Removing the NAT gateway saves $33.&lt;/p&gt;

&lt;p&gt;We looked at the version of this problem that shows up later, when the app is real, in &lt;a href="https://hostim.dev/blog/cloud-rent-in-action" rel="noopener noreferrer"&gt;Cloud Rent in Action: How €50 Turns Into €200+&lt;/a&gt; and &lt;a href="https://hostim.dev/blog/render-vs-railway-vs-fly-pricing" rel="noopener noreferrer"&gt;Render vs Railway vs Fly.io pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do AWS Savings Plans fix this?&lt;/strong&gt;&lt;br&gt;
They lower the Fargate line. They do not apply to the Application Load Balancer, the NAT gateway or Route 53, which together are 85% of the realistic bill. A three-year commitment changes the small number, not the big one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I avoid the NAT gateway?&lt;/strong&gt;&lt;br&gt;
Yes. Put the task in a public subnet with a public IP, which is the cheapest setup in the table above. It saves about $33 per month and is fine for a stateless container. It is not the default, and most tutorials will not do it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the AWS free tier cover this?&lt;/strong&gt;&lt;br&gt;
Partly, and only for 12 months. New accounts get 750 hours of ALB and some Fargate allowance, so the first year is cheaper. The bill above is what you pay in month 13. That is the number to plan with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Lambda instead of Fargate?&lt;/strong&gt;&lt;br&gt;
For workloads that are idle most of the time, Lambda with an HTTP API gateway avoids both the ALB and the NAT gateway, and a hello world can cost close to nothing. The trade-off is a different execution model: cold starts, a 15-minute limit, and rewriting your container as a handler. Good for small services with occasional traffic. It is not a drop-in replacement for a running app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is €2.50 real, or is there a metered surprise?&lt;/strong&gt;&lt;br&gt;
It is flat. Hostim bills per plan, not per request, per GB or per seat. The &lt;a href="https://hostim.dev/docs/billing/pricing-model" rel="noopener noreferrer"&gt;pricing model doc&lt;/a&gt; has the full tables. The honest trade-off is different: it is a small, founder-led platform, so weigh the track record and the support scale, not the feature list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One hello world on AWS costs &lt;strong&gt;$32 to $69 per month&lt;/strong&gt;. &lt;strong&gt;74% to 85% of that is the load balancer and the NAT gateway&lt;/strong&gt;, not compute.&lt;/li&gt;
&lt;li&gt;The same thing on a €6 VPS is &lt;strong&gt;about 10 times cheaper&lt;/strong&gt;, and you manage the machine yourself.&lt;/li&gt;
&lt;li&gt;Bare metal costs &lt;strong&gt;€97.30 per month&lt;/strong&gt;. It is the wrong tool for one app and the right tool for sixteen.&lt;/li&gt;
&lt;li&gt;Hetzner's June 2026 price rise hit the dedicated-vCPU plans hardest (+144% to +169%). The cheap shared plans went up much less (+33% to +38%).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does your hello world cost? If you have a real bill that contradicts any of these lines, I would like to see it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I run &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim.dev&lt;/a&gt;, a flat-price container hosting platform in the EU. This post was originally published &lt;a href="https://hostim.dev/blog/hello-world-cost-aws-vs-bare-metal" rel="noopener noreferrer"&gt;on our blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>docker</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>PostgreSQL Benchmark: AWS RDS vs Hostim vs Self-Hosted on Hetzner (2026)</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Tue, 07 Jul 2026 11:04:02 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/postgresql-benchmark-aws-rds-vs-hostim-vs-self-hosted-on-hetzner-2026-202</link>
      <guid>https://dev.to/pavel-hostim/postgresql-benchmark-aws-rds-vs-hostim-vs-self-hosted-on-hetzner-2026-202</guid>
      <description>&lt;p&gt;Short answer first: at the same size (2 vCPU / 4 GB, PostgreSQL 16), &lt;strong&gt;Hostim had the fastest writes&lt;/strong&gt;, about 2.5× the write throughput of AWS RDS &lt;code&gt;db.t4g.medium&lt;/code&gt; and 2.1× a default self-hosted Postgres on Hetzner. &lt;strong&gt;Hetzner had the fastest reads&lt;/strong&gt;, on raw per-core CPU speed. &lt;strong&gt;RDS was slowest or near-slowest on both&lt;/strong&gt;, and its listed price is the smallest part of the real bill.&lt;/p&gt;

&lt;p&gt;This post shows every number, the exact commands to reproduce them, and (because it changes the conclusion) what high availability actually costs on each platform.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Benchmarks run &lt;strong&gt;July 2026&lt;/strong&gt; on PostgreSQL 16, in a central-Europe region. Prices change often, so check each provider's live pricing page before you commit. The &lt;em&gt;shape&lt;/em&gt; of the result changes far less than the exact numbers.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What we compared
&lt;/h2&gt;

&lt;p&gt;Three ways to run a small production Postgres, all at &lt;strong&gt;2 vCPU / 4 GB RAM, PostgreSQL 16&lt;/strong&gt;, in a central-Europe region:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offering&lt;/th&gt;
&lt;th&gt;Instance&lt;/th&gt;
&lt;th&gt;Listed price / month&lt;/th&gt;
&lt;th&gt;Replicated (failover)?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hostim managed Postgres&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;drp-50&lt;/code&gt; (2 vCPU / 4 GB)&lt;/td&gt;
&lt;td&gt;€50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, by default&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS RDS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;db.t4g.medium&lt;/code&gt; (2 vCPU / 4 GB)&lt;/td&gt;
&lt;td&gt;~$48 (instance only)&lt;/td&gt;
&lt;td&gt;No (single-AZ)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;Hetzner CPX22 (2 vCPU AMD / 4 GB, shared)&lt;/td&gt;
&lt;td&gt;€19.49&lt;/td&gt;
&lt;td&gt;No (single node)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "replicated" column matters a lot for both price and performance. It gets its own section below.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;The goal was a like-for-like test, not a flattering one. The rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same DB size:&lt;/strong&gt; 2 vCPU / 4 GB on every target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same Postgres major version:&lt;/strong&gt; 16.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate load generator per target&lt;/strong&gt;, one network hop from the database, in the same region, never on the database box itself. Each client had 4 vCPU so the client was never the bottleneck (checked with &lt;code&gt;mpstat&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same workload:&lt;/strong&gt; &lt;code&gt;pgbench&lt;/code&gt;, scale factor 50 (about 750 MB, which fits in RAM so the test measures CPU and the commit path, not cold disk reads), 300-second runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Client setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; postgresql-contrib nmap sysstat   &lt;span class="c"&gt;# pgbench + nping&lt;/span&gt;
pgbench &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workload, run identically against each database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# initialise (~750 MB)&lt;/span&gt;
pgbench &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 50

&lt;span class="c"&gt;# write-heavy, TPC-B-like, 4 clients, 5 minutes&lt;/span&gt;
pgbench &lt;span class="nt"&gt;-c&lt;/span&gt; 4 &lt;span class="nt"&gt;-j&lt;/span&gt; 4 &lt;span class="nt"&gt;-T&lt;/span&gt; 300

&lt;span class="c"&gt;# read-only, 8 clients, 5 minutes&lt;/span&gt;
pgbench &lt;span class="nt"&gt;-c&lt;/span&gt; 8 &lt;span class="nt"&gt;-j&lt;/span&gt; 4 &lt;span class="nt"&gt;-T&lt;/span&gt; 300 &lt;span class="nt"&gt;-S&lt;/span&gt;

&lt;span class="c"&gt;# single connection, write, 1 minute (isolates commit/fsync latency)&lt;/span&gt;
pgbench &lt;span class="nt"&gt;-c&lt;/span&gt; 1 &lt;span class="nt"&gt;-T&lt;/span&gt; 60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Network latency was measured with a TCP ping to port 5432 (&lt;code&gt;nping --tcp -p 5432&lt;/code&gt;) and a &lt;code&gt;SELECT 1&lt;/code&gt; round-trip, because ICMP is not open on every platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration parity, stated plainly:&lt;/strong&gt; the self-hosted Hetzner node ran &lt;strong&gt;stock Postgres defaults&lt;/strong&gt; (&lt;code&gt;shared_buffers&lt;/code&gt; 128 MB, no tuning). That is the realistic "install Postgres and go" baseline. RDS ran on its default parameter group, which AWS tunes for the instance size. Hostim runs its own managed tuning. In other words, the two managed options are tuned out of the box and the self-hosted default is not, and that difference is part of what you are comparing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;All runs completed with &lt;strong&gt;zero failed transactions&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Write throughput (4 clients, TPC-B)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;TPS&lt;/th&gt;
&lt;th&gt;Avg latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hostim &lt;code&gt;drp-50&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,708&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.48 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner (default)&lt;/td&gt;
&lt;td&gt;1,303&lt;/td&gt;
&lt;td&gt;3.07 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS RDS &lt;code&gt;t4g.medium&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,080&lt;/td&gt;
&lt;td&gt;3.71 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hostim did about &lt;strong&gt;2.5× the write throughput of RDS&lt;/strong&gt; and 2.1× the default Hetzner node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Single-connection write latency (1 client)
&lt;/h3&gt;

&lt;p&gt;This isolates the commit path. Every transaction waits for a WAL flush (&lt;code&gt;synchronous_commit = on&lt;/code&gt; is the default), so it mostly measures storage fsync latency.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;TPS&lt;/th&gt;
&lt;th&gt;Avg latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hostim &lt;code&gt;drp-50&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;871&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.15 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS RDS &lt;code&gt;t4g.medium&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;416&lt;/td&gt;
&lt;td&gt;2.41 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner (default)&lt;/td&gt;
&lt;td&gt;276&lt;/td&gt;
&lt;td&gt;3.63 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both self-hosted and RDS pay for network-attached block storage on the commit path. This is the single biggest driver of the write-throughput gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Read throughput (8 clients, SELECT-only)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;TPS&lt;/th&gt;
&lt;th&gt;Avg latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hetzner (default)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20,068&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.40 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hostim &lt;code&gt;drp-50&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;14,333&lt;/td&gt;
&lt;td&gt;0.56 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS RDS &lt;code&gt;t4g.medium&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;13,261&lt;/td&gt;
&lt;td&gt;0.60 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reads scale with CPU, and the Hetzner box took this one on raw per-core AMD speed with no orchestration layer in the path. Hostim and RDS were close, with RDS last. Note that &lt;code&gt;db.t4g.medium&lt;/code&gt; is a &lt;strong&gt;burstable&lt;/strong&gt; instance: over a sustained 300-second run it can drop to its CPU-credit baseline, which is what the ~$48 tier is designed to provide. A workload that needs sustained CPU would move to an &lt;code&gt;m&lt;/code&gt;-class RDS instance, which costs roughly twice as much.&lt;/p&gt;

&lt;h3&gt;
  
  
  Network latency
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;TCP RTT (avg)&lt;/th&gt;
&lt;th&gt;&lt;code&gt;SELECT 1&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hostim &lt;code&gt;drp-50&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.48 ms&lt;/td&gt;
&lt;td&gt;~0.29 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner (default)&lt;/td&gt;
&lt;td&gt;0.71 ms&lt;/td&gt;
&lt;td&gt;~0.39 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS RDS &lt;code&gt;t4g.medium&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.50 ms&lt;/td&gt;
&lt;td&gt;~0.58 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The price you actually pay
&lt;/h2&gt;

&lt;p&gt;This is where the listed numbers stop being useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS RDS: $48 is not the bill
&lt;/h3&gt;

&lt;p&gt;The ~$48 is the &lt;strong&gt;instance only&lt;/strong&gt;, single-AZ, on-demand. On top of that you pay, metered separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt;: gp3 is billed per GB-month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IOPS and throughput&lt;/strong&gt; above the gp3 baseline: billed if you provision more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt;: retained backup storage beyond your volume size is billed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data transfer&lt;/strong&gt;: egress and cross-AZ traffic are billed per GB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One storage dropdown makes this concrete. Pick &lt;strong&gt;Provisioned IOPS SSD&lt;/strong&gt; in the create-database wizard and RDS defaults to &lt;strong&gt;3000 provisioned IOPS&lt;/strong&gt;, billed per IOPS-month at about &lt;strong&gt;$300/month&lt;/strong&gt; (io1 ≈ $0.10/IOPS, io2 ≈ $0.125/IOPS) on top of the instance and the GB storage. The &lt;strong&gt;gp3&lt;/strong&gt; default includes the same 3000 IOPS and 125 MB/s free up to 400 GB, so there it costs nothing. Same database, ~$300/month apart, from one dropdown.&lt;/p&gt;

&lt;p&gt;A realistic single-AZ &lt;code&gt;db.t4g.medium&lt;/code&gt; with a modest volume and normal traffic lands well above the sticker once storage, IOPS, backups, and egress are counted, and the exact figure changes month to month. Treat $48 as a floor, not a price. (We wrote about this metered-bill pattern in &lt;a href="https://hostim.dev/blog/cloud-rent-in-action" rel="noopener noreferrer"&gt;Cloud Rent in Action&lt;/a&gt; and &lt;a href="https://hostim.dev/blog/usage-based-pricing-creep" rel="noopener noreferrer"&gt;Usage-Based Pricing&lt;/a&gt;.)&lt;/p&gt;

&lt;h3&gt;
  
  
  High availability changes the comparison
&lt;/h3&gt;

&lt;p&gt;The results above are &lt;strong&gt;not&lt;/strong&gt; apples-to-apples on resilience, and this is the important part.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hostim &lt;code&gt;drp-50&lt;/code&gt; is replicated by default.&lt;/strong&gt; The €50 includes a standby and automatic failover. Nothing to configure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS RDS&lt;/strong&gt; needs &lt;strong&gt;Multi-AZ&lt;/strong&gt; for a standby with failover. Multi-AZ runs a second instance and roughly &lt;strong&gt;doubles&lt;/strong&gt; the instance and storage cost. It also makes writes &lt;strong&gt;slower&lt;/strong&gt;, because commits wait on the synchronous standby, so a fair, HA-to-HA comparison would put RDS writes &lt;em&gt;below&lt;/em&gt; the single-AZ numbers measured above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted Hetzner&lt;/strong&gt; has no failover at all on one node. To match Hostim you add a &lt;strong&gt;second VPS&lt;/strong&gt; (another €20) and then build and operate the replication and failover yourself (streaming replication plus a tool like Patroni or repmgr, plus a routing layer), and you own every incident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Normalised to "replicated Postgres with automatic failover":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offering&lt;/th&gt;
&lt;th&gt;Roughly what HA costs / month&lt;/th&gt;
&lt;th&gt;Extra work&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hostim &lt;code&gt;drp-50&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;€50, included&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS RDS Multi-AZ&lt;/td&gt;
&lt;td&gt;~2× instance + storage, plus egress and backups&lt;/td&gt;
&lt;td&gt;None, but slower writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner, 2 nodes&lt;/td&gt;
&lt;td&gt;~€40 hardware&lt;/td&gt;
&lt;td&gt;You build and run failover yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the €50 Hostim number already includes the thing that doubles the AWS price and turns the Hetzner option into an operations project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;db.t4g.medium&lt;/code&gt; is burstable ARM; sustained runs can hit the CPU-credit baseline. That is the behaviour of the price-matched tier, not a misconfiguration.&lt;/li&gt;
&lt;li&gt;The self-hosted node used stock Postgres defaults on purpose. Tuning &lt;code&gt;shared_buffers&lt;/code&gt; and friends would raise its numbers, but doing that is your job when you self-host.&lt;/li&gt;
&lt;li&gt;RDS was single-AZ for the raw benchmark; Multi-AZ is slower and dearer, as noted above.&lt;/li&gt;
&lt;li&gt;The dataset fit in RAM (scale 50), which isolates compute. A dataset larger than RAM would put more weight on storage and widen the write gaps, not close them.&lt;/li&gt;
&lt;li&gt;Architectures differ (RDS is ARM Graviton, the others x86). This is what each provider sells at this price, so it is reported as-is.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;strong&gt;write-heavy&lt;/strong&gt; workloads at this size, Hostim was fastest by a clear margin, mostly due to storage fsync latency on the commit path.&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;read-heavy&lt;/strong&gt; workloads, a self-hosted box had the raw per-core CPU edge, if you are willing to tune it and operate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS RDS&lt;/strong&gt; at the price-matched tier was slowest or near-slowest on both, and its real cost (storage, IOPS, egress, and Multi-AZ for failover) is a multiple of the listed instance price.&lt;/li&gt;
&lt;li&gt;If you want &lt;strong&gt;replicated&lt;/strong&gt; Postgres without running it yourself, the flat, HA-included price is the number to compare against, not the single-node sticker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every number above is reproducible with the commands in the methodology section. If you are still deciding between managed and self-hosted in the first place, see &lt;a href="https://hostim.dev/blog/self-host-postgres-vs-supabase" rel="noopener noreferrer"&gt;Self-Host Postgres or Use Supabase?&lt;/a&gt; and &lt;a href="https://hostim.dev/blog/database-showdown" rel="noopener noreferrer"&gt;Which Database Should You Self-Host?&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  A managed, replicated Postgres at a flat price
&lt;/h2&gt;

&lt;p&gt;If the parts you dislike are the climbing AWS bill and the operations work of running your own failover, that is exactly what &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim&lt;/a&gt; is built to avoid. Managed Postgres is &lt;strong&gt;replicated by default&lt;/strong&gt;, at a flat, predictable price: &lt;code&gt;drp-50&lt;/code&gt; is €50/month with the standby included. There is also a free database tier to test the fit, and new projects get a &lt;strong&gt;free 5-day trial&lt;/strong&gt; with no card.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://hostim.dev/dashboard?preview=1&amp;amp;modal=1&amp;amp;compose=1" rel="noopener noreferrer"&gt;Spin up a managed Postgres on Hostim, free tier, no card&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Last updated: 2026-07-07. Benchmarks run 2026-07-07 on PostgreSQL 16, eu-central region.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>aws</category>
      <category>hetzner</category>
      <category>database</category>
    </item>
    <item>
      <title>Render vs Railway vs Fly.io: Pricing Compared (2026)</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Tue, 23 Jun 2026 05:03:13 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/render-vs-railway-vs-flyio-pricing-compared-2026-2e5p</link>
      <guid>https://dev.to/pavel-hostim/render-vs-railway-vs-flyio-pricing-compared-2026-2e5p</guid>
      <description>&lt;p&gt;Short answer first: the three platforms charge in three different shapes. &lt;strong&gt;Render&lt;/strong&gt; adds a workspace fee for teams on top of fixed instance prices. &lt;strong&gt;Railway&lt;/strong&gt; has no free tier and meters everything per second on top of a small plan fee. &lt;strong&gt;Fly.io&lt;/strong&gt; is pure pay-as-you-go per second, with no base plan fee at all. The right pick depends less on the headline price and more on which shape fits how you work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prices are accurate as of &lt;strong&gt;June 2026&lt;/strong&gt;. All three change pricing often — check the live page before you commit. The &lt;em&gt;shapes&lt;/em&gt; change far less than the numbers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The three shapes in one line each
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; — pick an instance size at a fixed monthly price, plus a flat workspace fee for teams. Predictable, with a real free tier that sleeps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Railway&lt;/strong&gt; — no free tier. A small monthly plan fee, then per-second metering of CPU, RAM, volumes, and egress on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fly.io&lt;/strong&gt; — no free tier, no seat fee. You pay per second for each running machine and for what it uses. The most "raw cloud" of the three.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is there a free tier?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Free tier?&lt;/th&gt;
&lt;th&gt;The catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Free web services &lt;strong&gt;spin down after ~15 min idle&lt;/strong&gt;, then cold-start. Fine for demos, not production.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Railway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;One-time &lt;strong&gt;$5 trial credit&lt;/strong&gt;, no card. Then you are on a paid plan.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fly.io&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;New accounts have &lt;strong&gt;no free allowance&lt;/strong&gt;. You pay from the first running machine.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How usage metering works
&lt;/h2&gt;

&lt;p&gt;Render bills &lt;strong&gt;fixed instance prices&lt;/strong&gt; — choose a size (Starter ~$7/mo for 0.5 CPU/512 MB, Standard ~$25/mo for 1 CPU/2 GB) and pay it whether the app is busy or idle.&lt;/p&gt;

&lt;p&gt;Railway and Fly.io both bill &lt;strong&gt;per second of actual use&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Railway&lt;/strong&gt; meters memory, CPU, volumes, and egress on top of your plan fee. An always-on 1 GB service runs in the low tens of dollars/month past the included credit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fly.io&lt;/strong&gt; bills each machine by the second. The smallest &lt;code&gt;shared-cpu-1x&lt;/code&gt; / 256 MB is ~&lt;strong&gt;$2.02/mo&lt;/strong&gt; if left on; more RAM is ~&lt;strong&gt;$5/GB/mo&lt;/strong&gt;; egress in NA/EU is &lt;strong&gt;$0.02/GB&lt;/strong&gt;. Stopped machines cost only their disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-second billing is cheaper for spiky or scale-to-zero workloads and harder to predict, because the bill moves with traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Render&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Railway&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Fly.io&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;Yes (sleeps ~15 min)&lt;/td&gt;
&lt;td&gt;No ($5 trial credit)&lt;/td&gt;
&lt;td&gt;No (new accounts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing model&lt;/td&gt;
&lt;td&gt;Fixed instance price&lt;/td&gt;
&lt;td&gt;Per-second usage + plan&lt;/td&gt;
&lt;td&gt;Per-second usage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workspace / team fee&lt;/td&gt;
&lt;td&gt;$25/mo flat (Pro)&lt;/td&gt;
&lt;td&gt;$20/mo flat (Pro)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry cost (solo)&lt;/td&gt;
&lt;td&gt;Free or ~$7/mo&lt;/td&gt;
&lt;td&gt;~$5–15/mo&lt;/td&gt;
&lt;td&gt;~$2–7/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for spiky traffic&lt;/td&gt;
&lt;td&gt;Weak (fixed size)&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost predictability&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low–medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When each wins
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; — predictable, fixed bills you can budget; a real free tier for demos (if you accept cold starts); simple over tunable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Railway&lt;/strong&gt; — clean DX with per-second billing that scales down when idle; great for spiky traffic; small enough team that the plan fee stays small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fly.io&lt;/strong&gt; — lowest raw compute cost, no seat fees, multi-region or scale-to-zero, and you are fine with more configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pattern behind all three
&lt;/h2&gt;

&lt;p&gt;All three start cheap and grow with usage, plan tier, or both. That is the normal SaaS shape — convenience now, a bill that climbs as you succeed. Worth paying &lt;strong&gt;if you use what you pay for&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the part you dislike is the &lt;em&gt;climbing&lt;/em&gt; bill, that is what we are building &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim&lt;/a&gt; to avoid: deploy a container or git repo, pay a flat predictable price for the resources you pick. New projects get a &lt;strong&gt;free 5-day trial&lt;/strong&gt; (no card), and managed Postgres/MySQL/Redis have a free tier of their own.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;We're building Hostim.dev to make this simpler — happy to answer any questions.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pricing</category>
      <category>devops</category>
      <category>cloud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Self-Host Postgres or Use Supabase? Here's How to Decide</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Mon, 08 Jun 2026 15:36:52 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/self-host-postgres-or-use-supabase-heres-how-to-decide-2d1h</link>
      <guid>https://dev.to/pavel-hostim/self-host-postgres-or-use-supabase-heres-how-to-decide-2d1h</guid>
      <description>&lt;p&gt;Short answer first: use &lt;strong&gt;Supabase&lt;/strong&gt; if you want Postgres plus auth, realtime, storage, and a dashboard as one managed bundle. Self-host Postgres – or use a managed Postgres – if you mostly need a database and your app already handles its own auth and logic.&lt;/p&gt;

&lt;p&gt;The choice is not really "Postgres vs Supabase". It's whether you need the extra layers Supabase puts on top of Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase is not a database
&lt;/h2&gt;

&lt;p&gt;Supabase &lt;strong&gt;runs&lt;/strong&gt; on PostgreSQL, but it's a stack of services around it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postgres&lt;/strong&gt; – the actual database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt; – user signup, login, JWT tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realtime&lt;/strong&gt; – live updates over websockets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt; – an S3-style file store&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Functions&lt;/strong&gt; – serverless functions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Studio&lt;/strong&gt; – dashboard + auto-generated REST/GraphQL API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "self-host Postgres or use Supabase" compares a plain database to a full backend. The honest question: &lt;strong&gt;do you need those extra layers, or just the database underneath them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A quick test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You use Supabase Auth, Storage, &lt;strong&gt;and&lt;/strong&gt; Realtime → Supabase earns its place.&lt;/li&gt;
&lt;li&gt;You use one of them → it's replaceable.&lt;/li&gt;
&lt;li&gt;You use none and treat it as "Postgres with a nice dashboard" → you want plain Postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Side-by-side comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Supabase (managed)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Self-hosted Supabase&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Plain Postgres (managed or self-hosted)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database engine&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in auth&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (bring your own)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realtime / websockets&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File storage&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard + auto API&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (use any SQL client)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups&lt;/td&gt;
&lt;td&gt;Managed (limits by plan)&lt;/td&gt;
&lt;td&gt;You manage&lt;/td&gt;
&lt;td&gt;Managed or you manage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost shape&lt;/td&gt;
&lt;td&gt;Metered, grows with usage&lt;/td&gt;
&lt;td&gt;Server cost + your time&lt;/td&gt;
&lt;td&gt;Database only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-host effort&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;High (many containers)&lt;/td&gt;
&lt;td&gt;Low–medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock-in&lt;/td&gt;
&lt;td&gt;Medium–high&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lock-in point decides it for many teams. Your &lt;strong&gt;data&lt;/strong&gt; is standard Postgres in every option (&lt;code&gt;pg_dump&lt;/code&gt; portable). The &lt;strong&gt;lock-in&lt;/strong&gt; is everything else: Auth tokens, Storage paths, Supabase-specific RLS policies, Edge Function code. The more Supabase-specific features you adopt, the harder the exit.&lt;/p&gt;

&lt;h2&gt;
  
  
  When each option wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pick managed Supabase when:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You're starting a new app and want auth, storage, and realtime working today.&lt;/li&gt;
&lt;li&gt;You'll genuinely use at least two of those features.&lt;/li&gt;
&lt;li&gt;You prefer to pay for convenience and not run infrastructure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Pick self-hosted Supabase when:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You want the Supabase feature set but need full data ownership or on-prem.&lt;/li&gt;
&lt;li&gt;You're ready to run and update a multi-container stack.&lt;/li&gt;
&lt;li&gt;Compliance or cost at scale justifies the extra ops work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Pick plain Postgres (managed or self-hosted) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You mainly need a reliable database, and your app already handles auth and logic.&lt;/li&gt;
&lt;li&gt;You want minimal lock-in and a simple, predictable cost.&lt;/li&gt;
&lt;li&gt;You value boring, standard Postgres you can move anywhere.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;Supabase is a strong choice when you use its full stack. The moment you only want the database underneath it, you're paying in cost and lock-in for layers you don't run.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Need a backend → &lt;strong&gt;Supabase&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Need a backend you fully own → &lt;strong&gt;self-hosted Supabase&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Need a database → &lt;strong&gt;plain Postgres&lt;/strong&gt;, managed or self-hosted.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write about this stuff while building &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim&lt;/a&gt;, a platform with managed Postgres built in. The full version of this post – with Docker Compose config and backup commands – is on the &lt;a href="https://hostim.dev/blog/self-host-postgres-vs-supabase" rel="noopener noreferrer"&gt;Hostim blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>Let's Encrypt Wildcard Certs in Kubernetes: cert-manager + DNS-01 (and When We Skipped It)</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Tue, 05 May 2026 16:52:23 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/lets-encrypt-wildcard-certs-in-kubernetes-cert-manager-dns-01-and-when-we-skipped-it-5bi8</link>
      <guid>https://dev.to/pavel-hostim/lets-encrypt-wildcard-certs-in-kubernetes-cert-manager-dns-01-and-when-we-skipped-it-5bi8</guid>
      <description>&lt;p&gt;If you run Kubernetes and want a wildcard TLS cert from Let's Encrypt — say &lt;code&gt;*.example.com&lt;/code&gt; — you need a DNS-01 challenge. HTTP-01 cannot prove control over a wildcard. That single fact rules out the easy path most tutorials show.&lt;/p&gt;

&lt;p&gt;This post is what we actually run at &lt;a href="https://hostim.dev/" rel="noopener noreferrer"&gt;Hostim.dev&lt;/a&gt; for our shared &lt;code&gt;*.region.hostim.dev&lt;/code&gt; wildcard. We use &lt;strong&gt;cert-manager for per-app certs&lt;/strong&gt; and a &lt;strong&gt;plain &lt;code&gt;certbot&lt;/code&gt; Ansible playbook for the wildcard&lt;/strong&gt;. Two different tools for two different jobs. We will explain why, then show the code for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two tools for one cluster?
&lt;/h2&gt;

&lt;p&gt;You can do everything with cert-manager. It supports DNS-01 with a long list of providers. So why are we running a second tool?&lt;/p&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Our DNS provider (Namecheap) does not have a stable cert-manager webhook.&lt;/strong&gt; There are community webhooks, but they break on upgrades. Maintaining one for a single cert is more work than running certbot once a quarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wildcard cert covers our shared ingress, not user apps.&lt;/strong&gt; It rotates rarely, lives in one namespace, and is read by every ingress as a TLS secret. cert-manager is built for the opposite case: many short-lived certs per Ingress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed cert-manager renewal at 3 a.m. is hard to debug.&lt;/strong&gt; A failed Ansible run on our laptop is a stack trace we can read.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For per-app domains (&lt;code&gt;my-app.user.tld&lt;/code&gt; with cert-manager + HTTP-01), the controller-driven model wins. For the one shared wildcard, the manual model wins. Use the right tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path A: cert-manager + HTTP-01 (per-app domains)
&lt;/h2&gt;

&lt;p&gt;This is the standard path. Most apps want a cert for one or two hostnames. HTTP-01 is the simplest challenge: cert-manager spins up a temporary pod, the ACME server hits &lt;code&gt;http://app.example.com/.well-known/acme-challenge/...&lt;/code&gt;, the pod responds, the cert is issued.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Install cert-manager
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/cert-manager/cert-manager/releases/download/v1.16.1/cert-manager.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for the three pods (&lt;code&gt;cert-manager&lt;/code&gt;, &lt;code&gt;cert-manager-webhook&lt;/code&gt;, &lt;code&gt;cert-manager-cainjector&lt;/code&gt;) to be ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Create a ClusterIssuer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://acme-v02.api.letsencrypt.org/directory&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;you@example.com&lt;/span&gt;
    &lt;span class="na"&gt;privateKeySecretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod-account&lt;/span&gt;
    &lt;span class="na"&gt;solvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http01&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it. cert-manager will register an ACME account on first use.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Annotate your Ingress
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cert-manager.io/cluster-issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app.example.com"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-example-com-tls&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. cert-manager sees the annotation, requests the cert, solves the HTTP-01 challenge, writes the cert into the &lt;code&gt;app-example-com-tls&lt;/code&gt; secret. Renewal is automatic.&lt;/p&gt;

&lt;p&gt;This works for any number of distinct hostnames. We do this exact thing for every user app on hostim.dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path B: certbot + DNS-01 (the wildcard)
&lt;/h2&gt;

&lt;p&gt;For &lt;code&gt;*.region.hostim.dev&lt;/code&gt;, HTTP-01 cannot work — the ACME server cannot resolve every possible subdomain. We need DNS-01: prove control over the parent domain by adding a TXT record.&lt;/p&gt;

&lt;p&gt;You can do this with cert-manager and a DNS-01 webhook for your provider. We chose not to. Here is the Ansible playbook we run instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flow
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Ansible writes two scripts: an auth hook (creates the TXT record) and a cleanup hook (deletes it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;certbot --manual --preferred-challenges dns&lt;/code&gt; runs the auth hook, waits for DNS to propagate, lets ACME verify, then runs the cleanup hook.&lt;/li&gt;
&lt;li&gt;The resulting &lt;code&gt;fullchain.pem&lt;/code&gt; and &lt;code&gt;privkey.pem&lt;/code&gt; get loaded into a Kubernetes Secret of type &lt;code&gt;kubernetes.io/tls&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every ingress in the shared namespace references that secret.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The playbook (trimmed)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Issue and upload wildcard TLS certificate&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sld&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;example"&lt;/span&gt;
    &lt;span class="na"&gt;tld&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com"&lt;/span&gt;
    &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eu-center"&lt;/span&gt;
    &lt;span class="na"&gt;wildcard_domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}.{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sld&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}.{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tld&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;local_tmp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/wildcard-{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;k8s_namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ingress-nginx"&lt;/span&gt;
    &lt;span class="na"&gt;k8s_secret_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wildcard-{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}-tls"&lt;/span&gt;

  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create certbot auth hook (creates the TXT record)&lt;/span&gt;
      &lt;span class="na"&gt;copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/certbot-auth-{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}.sh"&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0755"&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;#!/bin/bash&lt;/span&gt;
          &lt;span class="s"&gt;set -e&lt;/span&gt;
          &lt;span class="s"&gt;namecheap-cli setone \&lt;/span&gt;
            &lt;span class="s"&gt;--sld {{ sld }} --tld {{ tld }} \&lt;/span&gt;
            &lt;span class="s"&gt;--type TXT --name "_acme-challenge.{{ region }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--address "${CERTBOT_VALIDATION}" --ttl 60&lt;/span&gt;
          &lt;span class="s"&gt;# Wait for DNS to propagate&lt;/span&gt;
          &lt;span class="s"&gt;for i in {1..30}; do&lt;/span&gt;
            &lt;span class="s"&gt;val=$(dig TXT _acme-challenge.{{ region }}.{{ sld }}.{{ tld }} @1.1.1.1 +short | tr -d '"')&lt;/span&gt;
            &lt;span class="s"&gt;[[ "$val" == "${CERTBOT_VALIDATION}" ]] &amp;amp;&amp;amp; break&lt;/span&gt;
            &lt;span class="s"&gt;sleep 10&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
          &lt;span class="s"&gt;sleep 30  # belt and suspenders&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Issue wildcard certificate&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;certbot certonly --manual --preferred-challenges dns&lt;/span&gt;
        &lt;span class="s"&gt;--manual-auth-hook /tmp/certbot-auth-{{ region }}.sh&lt;/span&gt;
        &lt;span class="s"&gt;--manual-cleanup-hook /tmp/certbot-cleanup-{{ region }}.sh&lt;/span&gt;
        &lt;span class="s"&gt;--agree-tos -m you@example.com&lt;/span&gt;
        &lt;span class="s"&gt;--server https://acme-v02.api.letsencrypt.org/directory&lt;/span&gt;
        &lt;span class="s"&gt;-d "{{ wildcard_domain }}"&lt;/span&gt;
        &lt;span class="s"&gt;--work-dir {{ local_tmp }} --config-dir {{ local_tmp }}&lt;/span&gt;
        &lt;span class="s"&gt;--logs-dir {{ local_tmp }} --non-interactive&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create or update TLS Secret&lt;/span&gt;
      &lt;span class="na"&gt;kubernetes.core.k8s&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;k8s_namespace&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
          &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
          &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;k8s_secret_name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/tls&lt;/span&gt;
          &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;tls.crt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('file',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;local_tmp&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'/live/.../fullchain.pem')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;b64encode&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
            &lt;span class="na"&gt;tls.key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lookup('file',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;local_tmp&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'/live/.../privkey.pem')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;b64encode&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Reference the secret in your Ingress
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.region.example.com"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-region-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When does it run?
&lt;/h3&gt;

&lt;p&gt;We run the playbook every 60 days. Let's Encrypt certs are valid for 90 days, so 60 leaves a 30-day buffer. A simple cron on a bastion host is enough — we do not even need to automate this. The cost of a manual run twice a quarter is lower than the cost of debugging a webhook.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Unable to locate package 'appengine'" — a real gotcha we hit
&lt;/h2&gt;

&lt;p&gt;If you copy this playbook and your &lt;code&gt;certbot&lt;/code&gt; is from your distro's package manager, you may hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImportError: cannot import name 'appengine' from 'urllib3.contrib'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a Python env collision. System certbot (often 1.21) wants old &lt;code&gt;urllib3&lt;/code&gt;; you have a newer one in &lt;code&gt;~/.local/lib/python3.10/site-packages&lt;/code&gt;. The newer version dropped &lt;code&gt;appengine&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Quick fix — add &lt;code&gt;PYTHONNOUSERSITE: "1"&lt;/code&gt; to the certbot task's &lt;code&gt;environment&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Issue wildcard certificate&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;PYTHONNOUSERSITE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;certbot certonly --manual ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long-term fix — install certbot via snap or pipx so it has its own Python env.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you do it this way?
&lt;/h2&gt;

&lt;p&gt;Probably not. If your DNS provider has a stable cert-manager webhook (Cloudflare, Route53, DigitalOcean, Google Cloud DNS), use cert-manager for both per-app &lt;strong&gt;and&lt;/strong&gt; wildcard certs. It is simpler and renews automatically.&lt;/p&gt;

&lt;p&gt;The hybrid model only makes sense when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your DNS provider has no first-party or stable cert-manager support&lt;/li&gt;
&lt;li&gt;You have one wildcard, not many&lt;/li&gt;
&lt;li&gt;You would rather audit a 30-line shell script than a webhook deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For us those three are all true. For most teams, only the first might be — and even then, switching DNS provider is often easier than maintaining a webhook.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-app domains&lt;/strong&gt; → cert-manager + HTTP-01 + ClusterIssuer. One annotation per Ingress, automatic renewals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wildcards&lt;/strong&gt; → DNS-01 is mandatory. Use cert-manager with your DNS provider's webhook if it exists. Otherwise, a 60-day Ansible run with &lt;code&gt;certbot --manual&lt;/code&gt; and a TLS Secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two tools is fine.&lt;/strong&gt; Don't force one model onto two different problems.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Want to skip TLS entirely?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://hostim.dev/" rel="noopener noreferrer"&gt;Hostim.dev&lt;/a&gt; does this for you. Bring a Docker image or a git repo, get a cert and a domain.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>Should Small Teams Even Bother with Kubernetes?</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Sun, 26 Apr 2026 10:43:15 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/should-small-teams-even-bother-with-kubernetes-occ</link>
      <guid>https://dev.to/pavel-hostim/should-small-teams-even-bother-with-kubernetes-occ</guid>
      <description>&lt;p&gt;Most small teams hit the same question at some point: should we move to Kubernetes? The honest answer for the majority of them is no, but that answer alone is not very helpful. So here is the longer version, with real prices and a clear line where the answer flips to yes.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Kubernetes gives you
&lt;/h2&gt;

&lt;p&gt;Underneath the marketing, Kubernetes is four practical things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declarative scheduling&lt;/strong&gt; – you describe the desired state and a controller keeps the cluster in that state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing&lt;/strong&gt; – crashed pods restart, dead nodes are drained, replicas come back automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bin-packing&lt;/strong&gt; – many workloads share the same nodes with CPU and memory limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A standard API&lt;/strong&gt; – Deployments, Services, Ingress, Jobs, Secrets, all the same on any cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are real benefits. The catch is that you pay for them in money, time, or both.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it actually costs
&lt;/h2&gt;

&lt;p&gt;A realistic small-team setup looks like this: 3 services (API, worker, frontend), one Postgres, one Redis, around 50GB of storage. Here is what the same workload costs in three common shapes, all in eu-central-1 / Frankfurt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managed Kubernetes (AWS EKS)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;EKS control plane, 0.10 USD per hour: &lt;strong&gt;~67 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;3× t3.medium nodes (2 vCPU / 4GB): &lt;strong&gt;~92 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;RDS Postgres &lt;code&gt;db.t3.small&lt;/code&gt;, Single-AZ: &lt;strong&gt;~27 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;ElastiCache Redis &lt;code&gt;cache.t3.micro&lt;/code&gt;: &lt;strong&gt;~13 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;ALB base plus LCU: &lt;strong&gt;~15 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;50GB EBS gp3 plus ~200GB egress: &lt;strong&gt;~14 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total: around 228 €/mo&lt;/strong&gt;, before backups, observability, or any of your time.&lt;/p&gt;

&lt;p&gt;GKE used to give you the first cluster for free. That is gone now: control plane is 0.10 USD per hour, and you get a 74.40 USD monthly billing-account credit that offsets one zonal cluster. Regional clusters pay full price.&lt;/p&gt;

&lt;h3&gt;
  
  
  Single Hetzner box + Docker Compose
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;AX42 dedicated (8-core Ryzen 7 PRO, 64GB DDR5, NVMe): &lt;strong&gt;from 57 €/mo&lt;/strong&gt; (April 2026 pricing)&lt;/li&gt;
&lt;li&gt;Postgres, Redis, app – all containers on the same machine, isolated by Compose&lt;/li&gt;
&lt;li&gt;nginx and Let's Encrypt for HTTPS&lt;/li&gt;
&lt;li&gt;Storage Box BX11 (1TB) for backups: &lt;strong&gt;~4 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total: around 61 €/mo.&lt;/strong&gt; That box has enough headroom to run several more projects beside the main one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-hosted Kubernetes (k3s on Hetzner Cloud)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;3× CCX13 (2 dedicated vCPU / 8GB / 80GB SSD): &lt;strong&gt;~48 €/mo&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;You run the control plane, etcd, ingress controller, cert-manager, backups and monitoring yourself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Compute is around 48 €/mo, but the real cost is the hours you put into the cluster every week.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The ops cost nobody prices in
&lt;/h2&gt;

&lt;p&gt;Hosting is the cheap part. Kubernetes adds work that simply does not exist with Compose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cluster upgrades.&lt;/strong&gt; A new minor lands every four months. If you skip a few, the upgrade path becomes painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingress and cert-manager.&lt;/strong&gt; Works fine until cert-manager hits a CRD migration or your ingress controller deprecates an annotation you depend on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CNI debugging.&lt;/strong&gt; A misbehaving Calico or Cilium pod can take half a day to track down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC and ServiceAccounts.&lt;/strong&gt; Required even for trivial things like letting one pod read one secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PVCs and storage classes.&lt;/strong&gt; A reboot at the wrong moment can leave a volume stuck in &lt;code&gt;Terminating&lt;/code&gt; and you reading the controller logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;etcd.&lt;/strong&gt; Quiet most of the time, then your cluster is suddenly read-only at 2am and you are restoring from a snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Realistic estimate: 2 to 5 hours a week of cluster maintenance for a self-hosted setup. Managed clusters cost less time but more money, as the table above shows. For a 3-person team, 2-5 hours a week is 5-12% of one engineer's time spent on infrastructure that does not ship features.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Kubernetes is the right call
&lt;/h2&gt;

&lt;p&gt;There are real cases where the cost is justified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You run more than 20 services that need consistent deploys, secrets and networking&lt;/li&gt;
&lt;li&gt;Multi-region or multi-tenant with hard isolation per customer&lt;/li&gt;
&lt;li&gt;Compliance work (SOC 2, HIPAA) where audited RBAC and NetworkPolicies save weeks of paperwork&lt;/li&gt;
&lt;li&gt;Your team already knows Kubernetes well and Compose would slow them down&lt;/li&gt;
&lt;li&gt;Bursty workloads that genuinely benefit from horizontal autoscaling on shared nodes&lt;/li&gt;
&lt;li&gt;You are building a platform where the Kubernetes API itself is the product (operators, CRDs)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If two or more of those apply, Kubernetes earns its keep. If none do, you are paying for capabilities you will not use.&lt;/p&gt;




&lt;h2&gt;
  
  
  When it is not
&lt;/h2&gt;

&lt;p&gt;Most small teams have a workload that looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 to 5 services&lt;/li&gt;
&lt;li&gt;One Postgres, maybe a Redis&lt;/li&gt;
&lt;li&gt;A single region&lt;/li&gt;
&lt;li&gt;Fewer than 5 deploys a day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This fits comfortably on one Hetzner box with Docker Compose, or on a PaaS. No Kubernetes needed, much less money spent, and far less time on ops.&lt;/p&gt;

&lt;p&gt;The "we will need it eventually" argument is mostly survivorship bias. Most projects never reach the scale where Kubernetes is actually the cheapest option, and migrating later is easier than people claim. A &lt;code&gt;docker-compose.yml&lt;/code&gt; maps almost line-for-line to Deployments and Services when the day comes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Solo dev, 1-3 services&lt;/td&gt;
&lt;td&gt;Docker Compose on a VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small team, up to ~10 services, one region&lt;/td&gt;
&lt;td&gt;PaaS or Compose + Ansible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenant SaaS with isolation needs&lt;/td&gt;
&lt;td&gt;Kubernetes (managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance-heavy, audited infrastructure&lt;/td&gt;
&lt;td&gt;Kubernetes (managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Building a platform or operator&lt;/td&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Everyone else uses it"&lt;/td&gt;
&lt;td&gt;Not a real reason&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The middle ground
&lt;/h2&gt;

&lt;p&gt;A PaaS exists exactly for this gap. You get the useful parts of Kubernetes – self-healing, declarative deploys, automatic HTTPS, isolated namespaces – without running the cluster yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim.dev&lt;/a&gt; runs Kubernetes underneath, on bare metal in Germany, so you do not have to. You paste a &lt;code&gt;docker-compose.yml&lt;/code&gt; and get a deployed app with HTTPS, Postgres, Redis, volumes, metrics and logs.&lt;/p&gt;

&lt;p&gt;The same stack priced on Hostim:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3× shared App (2 vCPU / 2GB): &lt;strong&gt;13.50 €&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Postgres (10GB): &lt;strong&gt;10 €&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Redis (2.5GB): &lt;strong&gt;5 €&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;50GB volume: &lt;strong&gt;10 €&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total: 38.50 €/mo&lt;/strong&gt;, with HTTPS, metrics, logs and backups included.&lt;/p&gt;

&lt;p&gt;If you actually need Kubernetes, run Kubernetes. If you are reaching for it because it is the default answer, a PaaS or a single Hetzner box will probably serve you better, for less money and less weekend work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;👉 Try Hostim.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>startup</category>
      <category>devops</category>
    </item>
    <item>
      <title>Which Database Should You Self-Host? SQLite vs MySQL vs PostgreSQL vs Redis</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Wed, 08 Apr 2026 16:38:44 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/which-database-should-you-self-host-sqlite-vs-mysql-vs-postgresql-vs-redis-7j0</link>
      <guid>https://dev.to/pavel-hostim/which-database-should-you-self-host-sqlite-vs-mysql-vs-postgresql-vs-redis-7j0</guid>
      <description>&lt;p&gt;When you're deploying your own app, the database choice matters more than most people think. It affects performance, ops complexity, backups, and how much memory your server needs.&lt;/p&gt;

&lt;p&gt;There are four options you'll run into most often: &lt;strong&gt;SQLite, MySQL, PostgreSQL, and Redis&lt;/strong&gt;. They're not all the same kind of database – and that's the point. Here's when each one makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQLite – the zero-ops embedded database
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; small apps, prototypes, CLIs, single-user tools, edge deployments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strengths:&lt;/strong&gt; no server process, single file, zero config, instant setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weaknesses:&lt;/strong&gt; no concurrent writes, no replication, hard to scale past one instance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SQLite is not a server – it's a library that reads and writes a single file. That makes it perfect for apps where simplicity matters more than scale. If your app has one process writing to the database and modest traffic, SQLite will outperform anything else because there's no network round-trip. The moment you need concurrent writes or multiple app replicas, you've outgrown it.&lt;/p&gt;




&lt;h2&gt;
  
  
  MySQL – the reliable workhorse
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; web apps, CMS platforms, CRUD-heavy workloads, WordPress/Laravel stacks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strengths:&lt;/strong&gt; fast reads, mature replication, huge ecosystem, low memory footprint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weaknesses:&lt;/strong&gt; weaker JSON support, less strict by default, fewer advanced types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MySQL powers a massive chunk of the internet. It's battle-tested, well-documented, and runs well even on small VPS instances. If you're running a standard web app with mostly reads and simple queries, MySQL will serve you well without hogging resources. Just be aware that its default configs are more lenient than PostgreSQL – silent truncations and implicit type casts can bite you.&lt;/p&gt;




&lt;h2&gt;
  
  
  PostgreSQL – the feature-rich powerhouse
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; complex queries, data integrity, JSON workloads, GIS, analytics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strengths:&lt;/strong&gt; advanced types (JSONB, arrays, hstore), strong standards compliance, extensions ecosystem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weaknesses:&lt;/strong&gt; higher memory usage, more tuning needed, steeper learning curve for ops&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PostgreSQL is the database you pick when correctness and flexibility matter. It handles complex joins, window functions, CTEs, and full-text search natively. The extension ecosystem (PostGIS, pg_cron, pgvector) makes it a Swiss army knife. The trade-off: it's hungrier on resources and rewards careful tuning of &lt;code&gt;shared_buffers&lt;/code&gt;, &lt;code&gt;work_mem&lt;/code&gt;, and connection pooling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Redis – the in-memory speed layer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; caching, sessions, rate limiting, queues, pub/sub, leaderboards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strengths:&lt;/strong&gt; sub-millisecond reads, rich data structures (lists, sets, sorted sets, streams), built-in TTL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weaknesses:&lt;/strong&gt; data must fit in RAM, persistence is optional and lossy, not a primary data store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Redis isn't a replacement for a relational database – it's a complement. Use it for things that need to be fast and can tolerate occasional data loss: session tokens, cache layers, job queues. Redis Streams can even replace simple message brokers. Just don't store your source of truth here – if the server restarts between RDB snapshots, recent writes are gone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;MySQL&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Redis&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;Embedded&lt;/td&gt;
&lt;td&gt;Relational server&lt;/td&gt;
&lt;td&gt;Relational server&lt;/td&gt;
&lt;td&gt;In-memory store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ease of setup&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent writes&lt;/td&gt;
&lt;td&gt;❌ Single-writer&lt;/td&gt;
&lt;td&gt;✅ Good&lt;/td&gt;
&lt;td&gt;✅ Excellent&lt;/td&gt;
&lt;td&gt;✅ Very fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex queries&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;N/A (key-value)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory usage&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Low–moderate&lt;/td&gt;
&lt;td&gt;Moderate–high&lt;/td&gt;
&lt;td&gt;High (all data in RAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replication&lt;/td&gt;
&lt;td&gt;None built-in&lt;/td&gt;
&lt;td&gt;Mature&lt;/td&gt;
&lt;td&gt;Mature&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best self-host size&lt;/td&gt;
&lt;td&gt;Single instance&lt;/td&gt;
&lt;td&gt;Small–large&lt;/td&gt;
&lt;td&gt;Medium–large&lt;/td&gt;
&lt;td&gt;Any (as cache layer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Always (file)&lt;/td&gt;
&lt;td&gt;Always (disk)&lt;/td&gt;
&lt;td&gt;Always (disk)&lt;/td&gt;
&lt;td&gt;Optional (RDB/AOF)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  So which one should you choose?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Building a prototype or CLI tool?&lt;/strong&gt; → &lt;strong&gt;SQLite&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running a standard web app?&lt;/strong&gt; → &lt;strong&gt;MySQL&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need complex queries, JSONB, or extensions?&lt;/strong&gt; → &lt;strong&gt;PostgreSQL&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need a fast cache, session store, or queue?&lt;/strong&gt; → &lt;strong&gt;Redis&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most real-world apps end up using &lt;strong&gt;two&lt;/strong&gt;: a relational database (MySQL or PostgreSQL) for your data, and Redis for caching and sessions. That's not overkill – it's the right tool for each job.&lt;/p&gt;




&lt;h2&gt;
  
  
  Self-hosting these databases
&lt;/h2&gt;

&lt;p&gt;Running databases on a VPS means managing backups, updates, and disk space yourself. It's doable, but it's one more thing to maintain.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim.dev&lt;/a&gt;, MySQL, PostgreSQL, and Redis are built in – provisioned alongside your app with metrics and no extra config. Paste a &lt;code&gt;docker-compose.yml&lt;/code&gt; and your database is ready.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mysql</category>
      <category>sqlite</category>
      <category>redis</category>
    </item>
    <item>
      <title>Bastion Host &amp; GitHub Actions on Hostim.dev</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Thu, 08 Jan 2026 19:00:09 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/bastion-host-github-actions-on-hostimdev-g9j</link>
      <guid>https://dev.to/pavel-hostim/bastion-host-github-actions-on-hostimdev-g9j</guid>
      <description>&lt;p&gt;I haven't posted updates for a while, but several core features landed on Hostim.dev recently.&lt;/p&gt;

&lt;p&gt;Instead of shipping from a fixed roadmap, I'm following &lt;strong&gt;support-driven (customer-driven) development&lt;/strong&gt;: features move to the top of the queue once users actively need them.&lt;/p&gt;

&lt;p&gt;Over the past month, this resulted in three practical additions around &lt;strong&gt;Docker CI/CD&lt;/strong&gt;, &lt;strong&gt;GitHub Actions deployment&lt;/strong&gt;, and &lt;strong&gt;secure bastion host access&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  GitHub Actions deploy for Docker apps
&lt;/h2&gt;

&lt;p&gt;Hostim.dev now supports &lt;a href="https://hostim.dev/docs/apps/github-actions" rel="noopener noreferrer"&gt;&lt;strong&gt;GitHub Actions deployments&lt;/strong&gt;&lt;/a&gt; out of the box.&lt;/p&gt;

&lt;p&gt;You can trigger a deploy directly from GitHub Actions using a simple API call. This works well for common &lt;strong&gt;Docker CI/CD&lt;/strong&gt; setups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build and deploy on merge to &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Restart an app after pushing a new Docker image&lt;/li&gt;
&lt;li&gt;Manual deploys via &lt;code&gt;workflow_dispatch&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no OAuth and no hidden logic. Your workflow controls everything — branches, conditions, environments. Hostim only executes the requested action.&lt;/p&gt;

&lt;p&gt;This is especially useful if you already run &lt;strong&gt;CI/CD with Docker&lt;/strong&gt; and just want a clean deployment target.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bastion host for secure shell access to containers
&lt;/h2&gt;

&lt;p&gt;Each project now includes a built-in &lt;a href="https://hostim.dev/docs/services/bastion" rel="noopener noreferrer"&gt;&lt;strong&gt;SSH bastion host&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're unfamiliar: &lt;strong&gt;a bastion host is a hardened entry point&lt;/strong&gt; used to access private infrastructure without exposing services to the public internet.&lt;/p&gt;

&lt;p&gt;On Hostim.dev, the bastion host allows you to open a shell into running apps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;shell my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This answers common questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;What is a bastion host used for?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;How do I securely SSH into containers?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;How can I debug a production Docker app without public access?&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typical use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debugging production issues&lt;/li&gt;
&lt;li&gt;Running database migrations&lt;/li&gt;
&lt;li&gt;Inspecting environment variables&lt;/li&gt;
&lt;li&gt;Accessing internal services safely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bastion host is private, key-based, and isolated per project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Custom commands for Docker apps
&lt;/h2&gt;

&lt;p&gt;Apps can now override the container command.&lt;/p&gt;

&lt;p&gt;This enables common Docker patterns such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One image, multiple roles (web + worker)&lt;/li&gt;
&lt;li&gt;Background jobs using the same Docker image&lt;/li&gt;
&lt;li&gt;CI/CD pipelines that reuse images across environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This pairs naturally with &lt;strong&gt;Docker CI/CD pipelines&lt;/strong&gt;, where images are built once and reused consistently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this approach
&lt;/h2&gt;

&lt;p&gt;Many platforms ship features based on assumptions.&lt;/p&gt;

&lt;p&gt;Instead, these changes came directly from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"How do I deploy with GitHub Actions?"&lt;/li&gt;
&lt;li&gt;"How do I get shell access without exposing ports?"&lt;/li&gt;
&lt;li&gt;"How do I run workers with the same image?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Support questions shape the roadmap.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;More items are planned, but user feedback decides the order.&lt;/p&gt;

&lt;p&gt;If something feels missing, it's probably already on the list.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;https://hostim.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>ci</category>
    </item>
    <item>
      <title>A Better Umami Dashboard with Grafana</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Thu, 20 Nov 2025 17:24:38 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/a-better-umami-dashboard-with-grafana-1mfj</link>
      <guid>https://dev.to/pavel-hostim/a-better-umami-dashboard-with-grafana-1mfj</guid>
      <description>&lt;p&gt;Umami is great. Lightweight, privacy-friendly, no cookies, no tracking drama.&lt;br&gt;
We use it ourselves on Hostim.dev, and we ship a &lt;strong&gt;one-click Umami template&lt;/strong&gt; for anyone who wants simple, privacy-focused analytics.&lt;/p&gt;

&lt;p&gt;But once you start relying on analytics to make actual decisions, you hit the limits pretty quickly.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why the default Umami dashboard wasn't enough
&lt;/h2&gt;

&lt;p&gt;Umami intentionally keeps things minimal, but some gaps become obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No clear view of &lt;strong&gt;when&lt;/strong&gt; visitors peak during the day&lt;/li&gt;
&lt;li&gt;Hard to isolate &lt;strong&gt;bots&lt;/strong&gt; from real traffic&lt;/li&gt;
&lt;li&gt;No &lt;strong&gt;moving averages&lt;/strong&gt; or trend smoothing&lt;/li&gt;
&lt;li&gt;No grouped referrers (e.g. "search", "LLM", "other")&lt;/li&gt;
&lt;li&gt;Limited visibility into relationships between sessions and custom events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is criticism — Umami is intentionally simple.&lt;br&gt;
But sometimes you want more resolution.&lt;/p&gt;

&lt;p&gt;So I took the quickest path:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy Grafana → connect it to Umami's PostgreSQL → build a custom dashboard.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This took maybe ten minutes and unlocked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Daily heatmap&lt;/strong&gt; showing real traffic peaks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7-day moving averages&lt;/strong&gt; for referrers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qualified sessions&lt;/strong&gt; (≥2 pageviews) to filter out most bots&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Selectable custom events&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw stats&lt;/strong&gt; for the selected period&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Suddenly Umami became "actionable" instead of just "nice".&lt;/p&gt;


&lt;h2&gt;
  
  
  How to connect Grafana to Umami's PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Inside Grafana:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration → Data sources → Add data source → PostgreSQL&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fill in the credentials from your Umami database and save.&lt;/p&gt;

&lt;p&gt;You can now import our dashboard:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://grafana.com/grafana/dashboards/24431" rel="noopener noreferrer"&gt;&lt;strong&gt;Grafana.com Dashboard&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;If you prefer to self-host on your own VPS, here is a complete Docker Compose stack for Umami, PostgreSQL, and Grafana.&lt;/p&gt;
&lt;h3&gt;
  
  
  Full Docker Compose stack
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;umami&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;umami_pass&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;umami&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres_data:/var/lib/postgresql/data&lt;/span&gt;

  &lt;span class="na"&gt;umami&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/umami-software/umami:postgres-latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://umami:umami_pass@postgres:5432/umami&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_TYPE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
      &lt;span class="na"&gt;APP_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace_this_with_a_random_secret"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3000:3000"&lt;/span&gt;

  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GF_SERVER_DOMAIN=grafana.example.com&lt;/span&gt;
      &lt;span class="s"&gt;GF_SERVER_ROOT_URL=https://grafana.example.com&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3001:3000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Start everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Umami → &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Grafana → &lt;a href="http://localhost:3001" rel="noopener noreferrer"&gt;http://localhost:3001&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Grafana, configure a PostgreSQL data source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host: postgres
Port: 5432
User: umami
Password: umami_pass
Database: umami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import the dashboard using the ID from Grafana.com.&lt;/p&gt;




&lt;h2&gt;
  
  
  Don't want to run a server?
&lt;/h2&gt;

&lt;p&gt;If you don't want to manage Docker, OS maintenance, or networking, you can deploy the &lt;strong&gt;same stack&lt;/strong&gt; on Hostim.dev by simply pasting the Compose file above when creating a new project.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose &lt;strong&gt;Paste Docker Compose&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Use the YAML from this section&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hostim.dev will handle HTTPS, internal networking, logs, metrics, and persistence for all three services.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;Try Hostim.dev — deploy the full stack without touching SSH&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Already running Umami on Hostim.dev?
&lt;/h2&gt;

&lt;p&gt;If you deployed Umami using the one-click template, you don't need a new project. Just:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a &lt;strong&gt;separate Grafana App&lt;/strong&gt; in the &lt;strong&gt;same project&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;grafana/grafana:latest&lt;/code&gt; image&lt;/li&gt;
&lt;li&gt;Add the existing &lt;strong&gt;Umami PostgreSQL&lt;/strong&gt; as a Grafana data source&lt;/li&gt;
&lt;li&gt;Import the dashboard JSON&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both apps run on the same private project network, so they can communicate without exposing ports or adjusting firewall rules.&lt;/p&gt;

</description>
      <category>umami</category>
      <category>grafana</category>
      <category>analytics</category>
      <category>docker</category>
    </item>
    <item>
      <title>MetalLB on Hetzner Dedicated with vSwitch</title>
      <dc:creator>Pavel</dc:creator>
      <pubDate>Tue, 28 Oct 2025 17:00:41 +0000</pubDate>
      <link>https://dev.to/pavel-hostim/metallb-on-hetzner-dedicated-with-vswitch-1f9e</link>
      <guid>https://dev.to/pavel-hostim/metallb-on-hetzner-dedicated-with-vswitch-1f9e</guid>
      <description>&lt;p&gt;When running Kubernetes on Hetzner Dedicated, there is no cloud load balancer. But you &lt;em&gt;can&lt;/em&gt; provide public LoadBalancer IPs by attaching a routed IP range to a vSwitch and letting MetalLB announce addresses over L2.&lt;/p&gt;

&lt;p&gt;Our setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calico (VXLAN + WireGuard)&lt;/li&gt;
&lt;li&gt;kube-proxy IPVS with strictARP&lt;/li&gt;
&lt;li&gt;ingress-nginx for ingress traffic&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Assign a public subnet to your vSwitch
&lt;/h2&gt;

&lt;p&gt;Example routed block Hetzner provides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subnet:     123.45.67.32/29
Gateway:    123.45.67.33
Usable:     123.45.67.34–38
Broadcast:  123.45.67.39
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attach your dedicated servers to the vSwitch (VLAN ID e.g. 4000).&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Configure vSwitch VLAN on each node
&lt;/h2&gt;

&lt;p&gt;Each node gets a &lt;strong&gt;/32&lt;/strong&gt; from the subnet – Hetzner routes the whole /29 to your server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important note on routing table IDs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This guide uses routing table &lt;strong&gt;200&lt;/strong&gt; as an example.&lt;/p&gt;

&lt;p&gt;If you are running &lt;strong&gt;Cilium&lt;/strong&gt;, avoid table &lt;code&gt;200&lt;/code&gt;: Cilium currently flushes all routes in table 200 on startup, which breaks vSwitch routing.&lt;/p&gt;

&lt;p&gt;For Cilium-based installations, &lt;strong&gt;any other unused routing table ID works&lt;/strong&gt; (for example &lt;code&gt;201&lt;/code&gt;, &lt;code&gt;300&lt;/code&gt;, or &lt;code&gt;1001&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Reference: &lt;a href="https://github.com/cilium/cilium/issues/38531" rel="noopener noreferrer"&gt;https://github.com/cilium/cilium/issues/38531&lt;/a&gt;&lt;br&gt;
Create &lt;code&gt;/etc/netplan/10-vlan-4000.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;renderer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networkd&lt;/span&gt;
  &lt;span class="na"&gt;vlans&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;vlan4000&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
      &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eno1&lt;/span&gt;
      &lt;span class="na"&gt;mtu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1400&lt;/span&gt;
      &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;123.45.67.38/32&lt;/span&gt; &lt;span class="c1"&gt;# node-specific&lt;/span&gt;
      &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;
          &lt;span class="na"&gt;via&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;123.45.67.33&lt;/span&gt;
          &lt;span class="na"&gt;on-link&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;123.45.67.32/29&lt;/span&gt;
          &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;link&lt;/span&gt;
          &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
      &lt;span class="na"&gt;routing-policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;123.45.67.32/29&lt;/span&gt;
          &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
          &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;123.45.67.32/29&lt;/span&gt;
          &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
          &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;123.45.67.32/29&lt;/span&gt;
          &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.233.0.0/18&lt;/span&gt;
          &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;254&lt;/span&gt;
          &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;123.45.67.32/29&lt;/span&gt;
          &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.233.64.0/18&lt;/span&gt;
          &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;254&lt;/span&gt;
          &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;netplan apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. Required sysctl settings
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/etc/sysctl.d/999-metallb.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net.ipv4.conf.all.arp_ignore=1
net.ipv4.conf.all.arp_announce=2
net.ipv4.conf.all.rp_filter=0
net.ipv4.conf.default.rp_filter=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;arp_ignore=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only reply to ARP queries for an IP &lt;strong&gt;on the correct interface&lt;/strong&gt; – prevents conflicting replies from Calico/VXLAN.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;arp_announce=2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Send ARP only from the &lt;strong&gt;interface that owns the VIP&lt;/strong&gt;, required when MetalLB moves VIPs between nodes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rp_filter=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disable strict reverse-path filtering – otherwise nodes drop return traffic sourced from VIPs or remote pods.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  4. kube-proxy + Calico adjustments
&lt;/h2&gt;

&lt;p&gt;Enable strictARP in kube-proxy (IPVS mode):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubeproxy.config.k8s.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KubeProxyConfiguration&lt;/span&gt;
&lt;span class="na"&gt;ipvs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;strictARP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MTU must account for VXLAN + vSwitch + WireGuard overhead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Calico MTU: 1280 (consistent across nodes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Deploy MetalLB
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IPAddressPool&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vswitch&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;123.45.67.34-123.45.67.36&lt;/span&gt; &lt;span class="c1"&gt;# free VIPs&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;L2Advertisement&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;l2&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ipAddressPools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vswitch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;interfaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vlan4000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart MetalLB speakers to pick up interface binding.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Ingress service configuration
&lt;/h2&gt;

&lt;p&gt;For ingress-nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;externalTrafficPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pro:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Preserves client IP&lt;/li&gt;
&lt;li&gt;Prevents traffic hairpin across nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tradeoff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only one node handles a given connection (acceptable for ingress)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  7. Verification
&lt;/h2&gt;

&lt;p&gt;Confirm that your ingress-nginx Service received a public VIP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc &lt;span class="nt"&gt;-n&lt;/span&gt; ingress-nginx ingress-nginx-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                       TYPE           CLUSTER-IP      EXTERNAL-IP    PORT(S)                      AGE
ingress-nginx-controller   LoadBalancer   10.233.53.156   123.45.67.35   80:30440/TCP,443:30477/TCP   17d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect the Service events to see which node currently advertises the VIP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe svc &lt;span class="nt"&gt;-n&lt;/span&gt; ingress-nginx ingress-nginx-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Events:
  Normal  nodeAssigned  ...  metallb-speaker  announcing from node "control-plane-1" with protocol "layer2"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify reachability from &lt;strong&gt;outside&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://123.45.67.35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Failover test
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Identify the active announcer from the above events&lt;/li&gt;
&lt;li&gt;Shut that node down abruptly:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;poweroff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Re-run:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://123.45.67.35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: traffic continues within ~1–2 seconds as another node picks up the VIP.&lt;/p&gt;

&lt;p&gt;➡️ Note: VIPs &lt;strong&gt;do not appear&lt;/strong&gt; in &lt;code&gt;ip addr&lt;/code&gt; on nodes; they are held in IPVS and advertised via ARP. That is normal.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;Acknowledgment&lt;/p&gt;

&lt;p&gt;Thanks to Oleksandr Vorona (DevOps at Dysnix) for reporting a Cilium routing table conflict and helping improve this guide.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dysnix.com" rel="noopener noreferrer"&gt;https://dysnix.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;This gives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Public LoadBalancer IPs&lt;/li&gt;
&lt;li&gt;Fast failover (~1-2s)&lt;/li&gt;
&lt;li&gt;Clean separation: pod networking via VXLAN/WireGuard, external via vSwitch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alternative approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hetzner Cloud Load Balancer (simpler, works with Dedicated too)&lt;/li&gt;
&lt;li&gt;Cilium with L2 announcements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We run hosting infrastructure, so controlling ingress networking ourselves matters (mostly to prove the point really). Hetzner still runs the vSwitch underneath, but it's more independent than relying on the cloud LB.&lt;/p&gt;

&lt;p&gt;And if you'd rather not handle any of this yourself – &lt;b&gt;&lt;a href="https://hostim.dev" rel="noopener noreferrer"&gt;Hostim.dev&lt;/a&gt;&lt;/b&gt; is now live. You can deploy your Docker or Compose apps with built-in databases, volumes, HTTPS, and logs – all in one place, ready in minutes.&lt;/p&gt;

</description>
      <category>metallb</category>
      <category>kubernetes</category>
      <category>hetzner</category>
      <category>networking</category>
    </item>
  </channel>
</rss>
