<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>System Engineer knowledge hub on nuteo.info</title><link>https://nuteo.info/en/</link><description>Recent content in System Engineer knowledge hub on nuteo.info</description><generator>Hugo</generator><language>en</language><copyright>© 2026 Nuteo</copyright><lastBuildDate>Mon, 03 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://nuteo.info/en/index.xml" rel="self" type="application/rss+xml"/><item><title>About</title><link>https://nuteo.info/en/about/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/about/</guid><description>&lt;h1 id="about-nuteoinfo"&gt;About nuteo.info&lt;/h1&gt;
&lt;p&gt;I&amp;rsquo;m &lt;strong&gt;Nuteo&lt;/strong&gt;, a System Engineer with &lt;strong&gt;7+ years&lt;/strong&gt; of experience running
production infrastructure — Linux servers, container platforms, CI/CD pipelines,
and the small automation scripts that keep everything alive at 3am.&lt;/p&gt;
&lt;h2 id="what-i-do"&gt;What I do&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;🖥️ &lt;strong&gt;Infrastructure&lt;/strong&gt;: Linux (Ubuntu/Debian), Docker, Caddy, Nginx, HAProxy&lt;/li&gt;
&lt;li&gt;☁️ &lt;strong&gt;Cloud&lt;/strong&gt;: AWS (EC2, S3, RDS), DigitalOcean, Hetzner&lt;/li&gt;
&lt;li&gt;🔄 &lt;strong&gt;CI/CD&lt;/strong&gt;: GitHub Actions, GitLab CI, Woodpecker CI&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;Container orchestration&lt;/strong&gt;: Docker Compose, basic Kubernetes&lt;/li&gt;
&lt;li&gt;🔐 &lt;strong&gt;Security&lt;/strong&gt;: SSH hardening, firewall, fail2ban, Let&amp;rsquo;s Encrypt, Vault&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;Monitoring&lt;/strong&gt;: Prometheus, Grafana, Netdata, Uptime Kuma&lt;/li&gt;
&lt;li&gt;🛠️ &lt;strong&gt;Automation&lt;/strong&gt;: Bash, Python, Ansible — anything that removes manual work&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="why-this-site-exists"&gt;Why this site exists&lt;/h2&gt;
&lt;p&gt;I spent years collecting notes, scripts, and configs across Notion, local files,
GitHub gists, and random &lt;code&gt;.txt&lt;/code&gt; files on servers. None of it was findable.
None of it helped anyone else.&lt;/p&gt;</description></item><item><title>CLI arsenal: the tools I actually use</title><link>https://nuteo.info/en/tools/cli-arsenal/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/tools/cli-arsenal/</guid><description>&lt;h1 id="cli-arsenal"&gt;CLI arsenal&lt;/h1&gt;
&lt;p&gt;This page grows over time. It&amp;rsquo;s not a &amp;ldquo;top 10&amp;rdquo; list — it&amp;rsquo;s the &lt;strong&gt;actual list&lt;/strong&gt;
of what&amp;rsquo;s in my shell history, my aliases, and my containers.&lt;/p&gt;
&lt;h2 id="file--search"&gt;File &amp;amp; search&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/eza-community/eza"&gt;&lt;code&gt;eza&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;ls&lt;/code&gt; replacement with git status, icons&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sharkdp/fd"&gt;&lt;code&gt;fd&lt;/code&gt;&lt;/a&gt; — fast &lt;code&gt;find&lt;/code&gt; with sane defaults&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/BurntSushi/ripgrep"&gt;&lt;code&gt;ripgrep&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;rg&lt;/code&gt;) — fast grep, ignores &lt;code&gt;.gitignore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sharkdp/bat"&gt;&lt;code&gt;bat&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;cat&lt;/code&gt; with syntax highlighting and paging&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/junegunn/fzf"&gt;&lt;code&gt;fzf&lt;/code&gt;&lt;/a&gt; — fuzzy finder for everything&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="process--resource"&gt;Process &amp;amp; resource&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://htop.dev"&gt;&lt;code&gt;htop&lt;/code&gt;&lt;/a&gt; — interactive process viewer&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/aristocratos/btop"&gt;&lt;code&gt;btop&lt;/code&gt;&lt;/a&gt; — the prettier one&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nicolargo.github.io/glances/"&gt;&lt;code&gt;glances&lt;/code&gt;&lt;/a&gt; — web-based, for monitoring without Grafana&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lsof-org/lsof"&gt;&lt;code&gt;lsof&lt;/code&gt;&lt;/a&gt; — what&amp;rsquo;s using that port? &lt;code&gt;lsof -i :8080&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="networking"&gt;Networking&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/ogham/dog"&gt;&lt;code&gt;dog&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;dig&lt;/code&gt; for humans&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se"&gt;&lt;code&gt;cURL&lt;/code&gt;&lt;/a&gt; — yes, still&lt;/li&gt;
&lt;li&gt;&lt;a href="https://httpie.org"&gt;&lt;code&gt;httpie&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;curl&lt;/code&gt; for humans, JSON-aware&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.bitwizard.nl/mtr/"&gt;&lt;code&gt;mtr&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;traceroute&lt;/code&gt; + &lt;code&gt;ping&lt;/code&gt; in one&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nmap.org"&gt;&lt;code&gt;nmap&lt;/code&gt;&lt;/a&gt; — for the occasional port scan&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="server-ops"&gt;Server ops&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docker.com"&gt;&lt;code&gt;docker&lt;/code&gt;&lt;/a&gt; + &lt;a href="https://docs.docker.com/compose/"&gt;&lt;code&gt;docker compose&lt;/code&gt;&lt;/a&gt; — containers, obviously&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com"&gt;&lt;code&gt;caddy&lt;/code&gt;&lt;/a&gt; — reverse proxy + auto HTTPS&lt;/li&gt;
&lt;li&gt;&lt;a href="https://restic.net"&gt;&lt;code&gt;restic&lt;/code&gt;&lt;/a&gt; — encrypted, deduplicated backups&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/fail2ban/fail2ban"&gt;&lt;code&gt;fail2ban&lt;/code&gt;&lt;/a&gt; — (or &lt;a href="https://nuteo.info/en/guides/crowdsec-setup/"&gt;CrowdSec&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.ubuntu.com/community/UFW"&gt;&lt;code&gt;ufw&lt;/code&gt;&lt;/a&gt; — firewall&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="monitoring"&gt;Monitoring&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.netdata.cloud"&gt;&lt;code&gt;netdata&lt;/code&gt;&lt;/a&gt; — gorgeous per-host dashboard, single Docker container&lt;/li&gt;
&lt;li&gt;&lt;a href="https://prometheus.io"&gt;&lt;code&gt;prometheus&lt;/code&gt;&lt;/a&gt; + &lt;a href="https://grafana.com"&gt;&lt;code&gt;grafana&lt;/code&gt;&lt;/a&gt; — for serious setups&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/louislam/uptime-kuma"&gt;&lt;code&gt;uptime-kuma&lt;/code&gt;&lt;/a&gt; — self-hosted status pages&lt;/li&gt;
&lt;li&gt;&lt;a href="https://humdi.net/vnstat/"&gt;&lt;code&gt;vnstat&lt;/code&gt;&lt;/a&gt; — bandwidth tracker (long-term trends)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="inside-the-shell"&gt;Inside the shell&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://fishshell.com"&gt;&lt;code&gt;fish&lt;/code&gt;&lt;/a&gt; — my shell. Autosuggestions out of the box.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://starship.rs"&gt;&lt;code&gt;starship&lt;/code&gt;&lt;/a&gt; — fast, minimal prompt&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/tmux/tmux"&gt;&lt;code&gt;tmux&lt;/code&gt;&lt;/a&gt; — terminal multiplexer (with &lt;a href="https://github.com/crismus/tmux-pop"&gt;&lt;code&gt;tmux-pop&lt;/code&gt;&lt;/a&gt; for fuzzy session switching)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ajeetdsouza/zoxide"&gt;&lt;code&gt;zoxide&lt;/code&gt;&lt;/a&gt; — smart &lt;code&gt;cd&lt;/code&gt; that learns your habits&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="reading-config"&gt;Reading config&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/dandavison/delta"&gt;&lt;code&gt;delta&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;git diff&lt;/code&gt; that doesn&amp;rsquo;t hurt your eyes&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jesseduffield/lazygit"&gt;&lt;code&gt;lazygit&lt;/code&gt;&lt;/a&gt; — TUI for git when I need it&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="aliases-i-cant-live-without"&gt;Aliases I can&amp;rsquo;t live without&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ~/.config/fish/config.fish (excerpt)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; ll &lt;span class="s2"&gt;&amp;#34;eza -l --git --icons&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; la &lt;span class="s2"&gt;&amp;#34;eza -la --git --icons&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; cat &lt;span class="s2"&gt;&amp;#34;bat --plain&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; grep &lt;span class="s2"&gt;&amp;#34;rg&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; k &lt;span class="s2"&gt;&amp;#34;kubectl&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; dc &lt;span class="s2"&gt;&amp;#34;docker compose&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; dps &lt;span class="s2"&gt;&amp;#34;docker ps --format &amp;#39;table {{.Names}}\t{{.Status}}\t{{.Ports}}&amp;#39;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; dlog &lt;span class="s2"&gt;&amp;#34;docker logs -f --tail 100&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; top &lt;span class="s2"&gt;&amp;#34;btop&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; ping &lt;span class="s2"&gt;&amp;#34;ping -c 5&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; ip &lt;span class="s2"&gt;&amp;#34;ip -c&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;What did I miss? Let me know — I&amp;rsquo;m always looking for the next tool.&lt;/p&gt;</description></item><item><title>CrowdSec: collaborative intrusion prevention</title><link>https://nuteo.info/en/guides/crowdsec-setup/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/guides/crowdsec-setup/</guid><description>&lt;h1 id="crowdsec-collaborative-intrusion-prevention"&gt;CrowdSec: collaborative intrusion prevention&lt;/h1&gt;
&lt;p&gt;fail2ban works, but it&amp;rsquo;s solo — every server sees the same SSH brute-force
attempts independently. CrowdSec fixes that: when one server bans an IP,
&lt;strong&gt;all&lt;/strong&gt; CrowdSec users benefit from the shared blocklist.&lt;/p&gt;
&lt;h2 id="install"&gt;Install&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# CrowdSec engine&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh &lt;span class="p"&gt;|&lt;/span&gt; sudo bash
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;apt install -y crowdsec
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Caddy bouncer (rejects bad IPs at the reverse proxy level)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;apt install -y crowdsec-caddy-bouncer
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;CrowdSec auto-detects running services and activates parsers. For a stock
setup, it will parse SSH, Caddy, and Nginx logs out of the box.&lt;/p&gt;</description></item><item><title>Healthchecks.io: cron monitoring ที่ใช้ได้จริง</title><link>https://nuteo.info/en/tools/healthchecks/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/tools/healthchecks/</guid><description>&lt;h1 id="healthchecksio-cron-monitoring"&gt;Healthchecks.io: cron monitoring&lt;/h1&gt;
&lt;p&gt;Cron jobs เงียบ ถ้า &lt;code&gt;restic-backup.sh&lt;/code&gt; fail เพราะ disk เต็ม ไม่มีใครบอก —
จนกว่าคุณจะต้องใช้ backup แล้วพบว่ามันว่างเปล่า&lt;/p&gt;
&lt;p&gt;Healthchecks.io แก้ปัญหานี้ cron jobs ของคุณ ping URL เมื่อเริ่มและเสร็จ
ถ้าไม่มี ping มาตามกำหนด คุณจะได้ alert&lt;/p&gt;
&lt;h2 id="ตงคา"&gt;ตั้งค่า&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;สมัครที่ &lt;a href="https://healthchecks.io"&gt;healthchecks.io&lt;/a&gt; (ฟรี 20 checks)&lt;/li&gt;
&lt;li&gt;สร้าง check:
&lt;ul&gt;
&lt;li&gt;Name: &amp;ldquo;nightly backup&amp;rdquo;&lt;/li&gt;
&lt;li&gt;Period: 24 hours&lt;/li&gt;
&lt;li&gt;Grace: 1 hour (alert ถ้าไม่มี ping ใน 25h)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;จะได้ URL เช่น &lt;code&gt;https://hc-ping.com/UUID&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="ใชใน-bash"&gt;ใช้ใน bash&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/bash
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -e
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -fsS -m &lt;span class="m"&gt;10&lt;/span&gt; --retry &lt;span class="m"&gt;5&lt;/span&gt; https://hc-ping.com/UUID/start &amp;gt; /dev/null
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;restic -r b2:my-bucket:vps-snapshots backup /etc /home /srv
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -fsS -m &lt;span class="m"&gt;10&lt;/span&gt; --retry &lt;span class="m"&gt;5&lt;/span&gt; https://hc-ping.com/UUID &amp;gt; /dev/null
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;set -e&lt;/code&gt; ทำให้ success ping รันเฉพาะตอน success ถ้า restic fail script
จะ exit โดยไม่ ping → healthchecks.io alert คุณ&lt;/p&gt;</description></item><item><title>Postmortem: the day Postgres took down production</title><link>https://nuteo.info/en/blog/postmortem-postgres-outage/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/blog/postmortem-postgres-outage/</guid><description>&lt;h1 id="postmortem-the-day-postgres-took-down-production"&gt;Postmortem: the day Postgres took down production&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: A missing index on a foreign key column, combined with a cron
job running during peak hours, caused a 47-minute outage on a billing API.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="timeline-all-times-utc"&gt;Timeline (all times UTC)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;14:00&lt;/strong&gt; — Cron job starts: &lt;code&gt;cleanup_old_invoices.sh&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14:02&lt;/strong&gt; — Customer reports slow checkout&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14:05&lt;/strong&gt; — PagerDuty alert: API latency p99 &amp;gt; 5s&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14:08&lt;/strong&gt; — On-call engineer (me) opens laptop&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14:14&lt;/strong&gt; — Identify: Postgres CPU 100%, hundreds of &lt;code&gt;DELETE&lt;/code&gt; queries waiting&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14:22&lt;/strong&gt; — Kill the cron job manually → queries drain in ~30s&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14:25&lt;/strong&gt; — Service recovering&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;14:47&lt;/strong&gt; — Full recovery, replication caught up&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Total impact&lt;/strong&gt;: 47 minutes of degraded service, ~120 failed transactions.&lt;/p&gt;</description></item><item><title>Production homelab: 50+ services on 3 nodes</title><link>https://nuteo.info/en/work/homelab-50-services/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/work/homelab-50-services/</guid><description>&lt;h1 id="production-homelab-50-services-on-3-nodes"&gt;Production homelab: 50+ services on 3 nodes&lt;/h1&gt;
&lt;p&gt;Three Ubuntu boxes. One Caddy reverse proxy. Fifty-plus self-hosted services
running the show — email, git, CI/CD, dashboards, password manager,
photo backup, and a small Kubernetes cluster for the things that need it.&lt;/p&gt;
&lt;h2 id="the-stack"&gt;The stack&lt;/h2&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Node&lt;/th&gt;
 &lt;th&gt;Role&lt;/th&gt;
 &lt;th&gt;Specs&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;edge-01&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reverse proxy + firewall&lt;/td&gt;
 &lt;td&gt;2 vCPU, 4GB RAM, public IP&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;app-01&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Application workloads&lt;/td&gt;
 &lt;td&gt;8 vCPU, 32GB RAM, NVMe&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;data-01&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Databases + storage&lt;/td&gt;
 &lt;td&gt;8 vCPU, 32GB RAM, 2×4TB HDD (RAID1)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="what-runs-where"&gt;What runs where&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;edge-01&lt;/strong&gt; (public-facing):&lt;/p&gt;</description></item><item><title>Server inventory &amp; deployment automation</title><link>https://nuteo.info/en/work/server-inventory/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/work/server-inventory/</guid><description>&lt;h1 id="server-inventory--deployment-automation"&gt;Server inventory &amp;amp; deployment automation&lt;/h1&gt;
&lt;p&gt;When you have more than 5 servers, you stop remembering which one runs what.
This tool fixes that. Single source of truth for: server, role, deploy status,
uptime, and alerts.&lt;/p&gt;
&lt;h2 id="what-it-does"&gt;What it does&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;📋 &lt;strong&gt;Inventory&lt;/strong&gt; — name, IP, role, environment, tags&lt;/li&gt;
&lt;li&gt;🚀 &lt;strong&gt;Deploy queue&lt;/strong&gt; — what&amp;rsquo;s deploying where, status&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;Dashboard&lt;/strong&gt; — uptime, last deploy, alerts&lt;/li&gt;
&lt;li&gt;🔔 &lt;strong&gt;Notifications&lt;/strong&gt; — Slack/Telegram when deployments fail&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;Stack&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Python&lt;/strong&gt; (FastAPI) for the API&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SQLite&lt;/strong&gt; for local store (Postgres optional)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTMX&lt;/strong&gt; for the dashboard (no React, no build step)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;systemd&lt;/strong&gt; + cron for agent-side collection&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ each host │ │ central │ │ dashboard │
│ │ │ API │ │ │
│ agent.py ──┼──▶ │ FastAPI ────┼──▶ │ /dashboard │
│ (cron 5m) │ │ SQLite │ │ (HTMX) │
└─────────────┘ └──────────────┘ └─────────────┘
 │
 ▼
 ┌─────────────┐
 │ Telegram │
 │ alerts │
 └─────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The agent is a 50-line Python script that runs via cron every 5 minutes,
collects &lt;code&gt;uptime&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;, &lt;code&gt;docker ps&lt;/code&gt;, and POSTs to the central API.&lt;/p&gt;</description></item><item><title>Setting up a VPS for production: the 2026 playbook</title><link>https://nuteo.info/en/guides/vps-setup-playbook/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/guides/vps-setup-playbook/</guid><description>&lt;h1 id="setting-up-a-vps-for-production"&gt;Setting up a VPS for production&lt;/h1&gt;
&lt;p&gt;Every time I set up a new VPS, I do the same dance: patch it, lock it down,
deploy Docker, set up backups. This guide is the playbook — every step I
actually run, in order.&lt;/p&gt;
&lt;h2 id="0-pre-flight"&gt;0. Pre-flight&lt;/h2&gt;
&lt;p&gt;Before I SSH in for the first time, I always:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Set the &lt;strong&gt;hostname&lt;/strong&gt; in the cloud console&lt;/li&gt;
&lt;li&gt;Add my SSH key (not password!)&lt;/li&gt;
&lt;li&gt;Note the &lt;strong&gt;public IPv4&lt;/strong&gt; and &lt;strong&gt;private IP&lt;/strong&gt; (if VPC)&lt;/li&gt;
&lt;li&gt;Create a &lt;strong&gt;DNS A record&lt;/strong&gt; pointing to the public IP&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="1-first-60-seconds"&gt;1. First 60 seconds&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade -y
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;apt install -y ufw fail2ban unattended-upgrades curl wget git htop vim
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;timedatectl set-timezone Asia/Bangkok
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set timezone first. Logs mean nothing without correct timestamps.&lt;/p&gt;</description></item><item><title>Uptime monitoring with Uptime Kuma</title><link>https://nuteo.info/en/guides/uptime-monitoring/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/guides/uptime-monitoring/</guid><description>&lt;h1 id="uptime-monitoring-with-uptime-kuma"&gt;Uptime monitoring with Uptime Kuma&lt;/h1&gt;
&lt;p&gt;Uptime Kuma is the self-hosted alternative to UptimeRobot / Pingdom.
It&amp;rsquo;s a single Docker container, has a beautiful UI, and supports
every monitor type I actually need.&lt;/p&gt;
&lt;h2 id="deploy"&gt;Deploy&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# docker-compose.yml&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;3.8&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;uptime-kuma&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;louislam/uptime-kuma:1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;uptime-kuma&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;unless-stopped&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;uptime_data:/app/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;127.0.0.1:3001:3001&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# Bind to localhost only&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;uptime_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then expose it via Caddy on a private subdomain (e.g. &lt;code&gt;status.internal.example.com&lt;/code&gt;)
behind your WireGuard VPN.&lt;/p&gt;</description></item><item><title>Welcome to nuteo.info</title><link>https://nuteo.info/en/blog/welcome/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/blog/welcome/</guid><description>&lt;h1 id="-welcome"&gt;👋 Welcome&lt;/h1&gt;
&lt;p&gt;The site is live. The design works. The content pipeline is AI-assisted.
Time to explain what this is and why.&lt;/p&gt;
&lt;h2 id="whats-working"&gt;What&amp;rsquo;s working&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;✅ Hugo + custom theme (dark by default, gradient hero)&lt;/li&gt;
&lt;li&gt;✅ Bilingual: TH + EN, switchable from the header&lt;/li&gt;
&lt;li&gt;✅ Sections: &lt;a href="https://nuteo.info/en/work/"&gt;Work&lt;/a&gt;, &lt;a href="https://nuteo.info/en/guides/"&gt;Guides&lt;/a&gt;, &lt;a href="https://nuteo.info/en/tools/"&gt;Tools&lt;/a&gt;, &lt;a href="https://nuteo.info/en/blog/"&gt;Blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;✅ RSS, sitemap, OpenGraph basics&lt;/li&gt;
&lt;li&gt;✅ Git-tracked for version control&lt;/li&gt;
&lt;li&gt;✅ Deployed via Docker + Caddy, zero-downtime&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="whats-next"&gt;What&amp;rsquo;s next&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;🔍 Full-text search (Pagefind)&lt;/li&gt;
&lt;li&gt;💬 Comments via Giscus (GitHub Discussions)&lt;/li&gt;
&lt;li&gt;📊 Analytics (Plausible — privacy-friendly)&lt;/li&gt;
&lt;li&gt;📝 More guides, more postmortems&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-ai-experiment-in-one-paragraph"&gt;The AI experiment, in one paragraph&lt;/h2&gt;
&lt;p&gt;Every piece of content on this site was drafted by AI.
Every line of theme CSS was written by AI. Every Docker config, every Caddyfile,
every deploy script. I reviewed and edited. I made decisions. But the typing? AI.&lt;/p&gt;</description></item><item><title>WireGuard VPN for admin access</title><link>https://nuteo.info/en/guides/wireguard-setup/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/guides/wireguard-setup/</guid><description>&lt;h1 id="wireguard-vpn-for-admin-access"&gt;WireGuard VPN for admin access&lt;/h1&gt;
&lt;p&gt;Once your VPS is in production, you don&amp;rsquo;t want to expose admin UIs
(Grafana, Netdata, internal tools) to the public internet. WireGuard
gives you a private tunnel to your VPS — only clients with the right
keys can reach those services.&lt;/p&gt;
&lt;h2 id="server-setup"&gt;Server setup&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;apt install -y wireguard
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Generate server keys&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wg genkey &lt;span class="p"&gt;|&lt;/span&gt; tee /etc/wireguard/server_private.key &lt;span class="p"&gt;|&lt;/span&gt; wg pubkey &amp;gt; /etc/wireguard/server_public.key
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chmod &lt;span class="m"&gt;600&lt;/span&gt; /etc/wireguard/server_private.key
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Server config&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat &amp;gt; /etc/wireguard/wg0.conf &lt;span class="s"&gt;&amp;lt;&amp;lt;EOF
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;[Interface]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;Address = 10.10.10.1/24
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;ListenPort = 51820
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PrivateKey = $(cat /etc/wireguard/server_private.key)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;# Forward traffic from VPN clients
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;[Peer]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;# admin-laptop
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PublicKey = &amp;lt;client_public_key&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;AllowedIPs = 10.10.10.2/32
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;EOF&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Enable IP forwarding&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;net.ipv4.ip_forward=1&amp;#34;&lt;/span&gt; &amp;gt;&amp;gt; /etc/sysctl.conf
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sysctl -p
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; --now wg-quick@wg0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Open firewall&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ufw allow 51820/udp comment &lt;span class="s1"&gt;&amp;#39;WireGuard&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="client-setup-macos-linux-ios-android"&gt;Client setup (macOS, Linux, iOS, Android)&lt;/h2&gt;
&lt;p&gt;For each client:&lt;/p&gt;</description></item><item><title>Zero-downtime Docker deploys with health checks</title><link>https://nuteo.info/en/work/zero-downtime-deploys/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://nuteo.info/en/work/zero-downtime-deploys/</guid><description>&lt;h1 id="zero-downtime-docker-deploys-with-health-checks"&gt;Zero-downtime Docker deploys with health checks&lt;/h1&gt;
&lt;p&gt;I don&amp;rsquo;t need Kubernetes for a 3-node homelab. But I do want zero-downtime
deploys. Here&amp;rsquo;s the bash script I use — pulls new image, swaps container
atomically, rolls back if the new container fails health check.&lt;/p&gt;
&lt;h2 id="the-script"&gt;The script&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/bash
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:?usage: deploy.sh &amp;lt;service&amp;gt; &amp;lt;image&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:?usage: deploy.sh &amp;lt;service&amp;gt; &amp;lt;image&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;COMPOSE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/srv/compose/&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;HEALTH_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HEALTH_URL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;http&lt;/span&gt;&lt;span class="p"&gt;://localhost:&lt;/span&gt;&lt;span class="nv"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;/health&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;OLD_CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_app_1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$COMPOSE_DIR&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Save current image for rollback&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;docker inspect --format&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;{{.Image}}&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$OLD_CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;▶ Pulling &lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker pull &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Update image in compose and start new container alongside old&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;▶ Starting new container&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; docker compose up -d --no-deps --scale &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; app
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Wait for new container to be healthy&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;▶ Waiting for health check&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;NEW_CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_app_2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;RETRIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;until&lt;/span&gt; docker inspect --format&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;{{.State.Health.Status}}&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$NEW_CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="p"&gt;|&lt;/span&gt; grep -q healthy&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;RETRIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;RETRIES-1&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$RETRIES&lt;/span&gt; -le &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;✗ Health check failed, rolling back&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; docker compose up -d --no-deps --scale &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; app
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; sleep &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Stop old container&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;▶ Stopping old container&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker compose up -d --no-deps --scale &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; app
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;✓ Deployed &lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="how-to-use"&gt;How to use&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./deploy.sh myapp myuser/myapp:v1.2.3
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="why-not-docker-rollout--kubectl"&gt;Why not docker-rollout / kubectl?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Simpler&lt;/strong&gt;: One file, ~30 lines, no Go binary&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No state&lt;/strong&gt;: Stateless nodes can run this without a cluster manager&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rollback included&lt;/strong&gt;: Keeps old container until health check passes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auditable&lt;/strong&gt;: Plain bash, every step is visible&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="limitations-be-honest"&gt;Limitations (be honest)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Single-node only (fine for my homelab, not for prod at scale)&lt;/li&gt;
&lt;li&gt;No blue/green across multiple hosts&lt;/li&gt;
&lt;li&gt;Health check is HTTP-only — for TCP services you&amp;rsquo;d want to extend it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For anything bigger, use Kubernetes. For a homelab or a single production
host, this is enough.&lt;/p&gt;</description></item></channel></rss>