[{"categories":["Linux","macOS"],"content":"As I previously discussed in my post on secret management in macOS and Linux, the real challenge of managing keys and tokens isn\u0026rsquo;t the encryption itself, but reducing accidental leakage without turning the sysadmin\u0026rsquo;s daily routine into a bureaucratic nightmare. Over the last few years, however, a duo of tools has gained significant traction and completely changed this dynamic: Mozilla SOPS and age. Together, they enable a declarative, GitOps-friendly, and extremely secure approach with virtually zero friction. This post is a detailed look at how these tools work and how to integrate them practically into your daily workflow.\nage: Modern Encryption for the Real World age (designed by Filippo Valsorda) was built with a simple premise: to be a modern, secure, and above all, uncomplicated file encryption tool. It was created specifically to replace GPG in everyday infrastructure use cases.\nWhile GPG is a monolith with decades of legacy and support for dozens of obsolete algorithms, age uses state-of-the-art cryptography by default (like X25519 and ChaCha20-Poly1305) and doesn\u0026rsquo;t try to manage your identity. It has no \u0026ldquo;trust networks\u0026rdquo; or local key databases.\nAn age key is simply a text file containing two strings:\nThe public key (which starts with age1...), used for encrypting. The private key (which starts with AGE-SECRET-KEY-1...), used for decrypting. age in Practice Installing age is trivial on any system. On macOS, you can use brew install age, and on most Linux distributions, it\u0026rsquo;s already in the official repositories (apt install age).\nTo generate a key pair, simply run:\nage-keygen -o key.txt The generated file will look something like this:\n# created: 2026-05-26T07:15:00-03:00 # public key: age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns AGE-SECRET-KEY-1R4X2QYLWMVPTQYP... Encrypting a file using the public key is straightforward:\nage -r age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns -o secrets.enc secrets.txt And to decrypt it using the private key:\nage -d -i key.txt secrets.enc That\u0026rsquo;s it. No background daemons, no expired keys locking your scripts, no public keys imported into a local database. Just plain text files and standard input/output streams.\nMozilla SOPS: Smart Partial Encryption While age solves file encryption brilliantly, it still operates on the entire file. If you have a 50-line YAML configuration file where only two lines are secrets (like the database password), encrypting the whole file with age introduces two classic version control issues:\nYou lose all visibility into what changed in Git (the diff becomes just an opaque binary block). Resolving merge conflicts in encrypted binary files is virtually impossible. This is where Mozilla SOPS (Secrets Operations) comes in.\nSOPS is an encrypted file editor that supports YAML, JSON, TOML, .env files, and binaries. Its killer feature is partial encryption: it parses the structure of your file and encrypts only the values, keeping the structural keys in plain text.\nSOPS in Action Imagine you have the following configuration file (secrets.yaml):\ndatabase: host: db.internal username: app_user password: \u0026#34;my-super-secret-password\u0026#34; api: token: \u0026#34;sk_live_abcdef123456\u0026#34; Running the SOPS command configured to use your age key:\nsops --encrypt --age age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns secrets.yaml \u0026gt; secrets.enc.yaml The resulting secrets.enc.yaml file will look like this:\ndatabase: host: db.internal username: app_user password: ENC[AES256_GCM,data:lTqgB4e5aY1G8pQ=,iv:...,tag:...] api: token: ENC[AES256_GCM,data:2u7gB4a...,iv:...,tag:...] sops: age: - recipient: age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns enc: | -----BEGIN AGE ENCRYPTED FILE----- ... -----END AGE ENCRYPTED FILE----- lastmodified: \u0026#34;2026-05-26T07:22:00Z\u0026#34; version: 3.9.0 Notice how elegant this is:\nThe keys database.host and database.username remain readable. Anyone viewing the repo knows the host is db.internal. Only the confidential values (password and token) are encrypted with AES-256-GCM. The metadata required to decrypt the file (including the recipient\u0026rsquo;s public key) is attached under the sops key. In Git, if you change the database host, the diff will show exactly that line changing. If you rotate the password, only the encrypted value line will change. The diff remains clean, readable, and useful.\nFurthermore, editing the file is completely transparent. If you set the SOPS_AGE_KEY_FILE=/path/to/key.txt environment variable and run:\nsops secrets.enc.yaml SOPS will read your private key, decrypt the file temporarily in memory, open your default editor (defined in $EDITOR), wait for your changes, re-encrypt the file, and save it back to disk once the editor is closed. The private key never touches the saved file, and the plain text never touches the disk.\nThe Power of .sops.yaml Passing public keys on the command line every time you encrypt a file is tedious and error-prone. SOPS solves this with a declarative configuration file placed at the root of your repository: .sops.yaml.\nThis file defines rules on which keys should encrypt which files based on regular expressions.\nHere is a real-world infrastructure example:\ncreation_rules: # Rule for production secrets - path_regex: secrets/production/.*\\.yaml$ key_groups: - age: - age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns # Main Dev (Janio) - age1r78k4u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlq928asl0212 # Prod Server Key - age1backup9u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqkey00 # Cold Backup Key # Rule for staging/development secrets - path_regex: secrets/staging/.*\\.yaml$ key_groups: - age: - age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns # Main Dev - age1dev01u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyvdev01 # Dev Team Key With this structure, when anyone on the team runs sops secrets/production/app.yaml, SOPS automatically reads the configuration and encrypts the file so that any one of the three recipients listed in the production rule can decrypt it using their respective age private key.\nThis eliminates the need to share private keys. The developer uses their key, the server uses its key, and the backup uses its key.\nAdding New Machines and Rotating Keys If you need to authorize a new server to read production secrets, the workflow is simple:\nGenerate an age key on the new server. Add the public key to the recipients list in .sops.yaml. On your admin machine, apply the new configuration to existing files by running: sops updatekeys secrets/production/app.yaml SOPS will decrypt the file using your private key and re-encrypt it, adding the new server to the recipients list. Then, simply commit the changes to Git.\nHow to Consume Secrets in Practice (Friction-Free) While the sops + age ecosystem is incredibly robust for storage, you still need to read these secrets to use them in your scripts, deploys, and applications. The good news is that SOPS offers several ways to do this with minimal friction.\n1. Injecting Secrets into the Environment via CLI (sops exec-env) If you have a script, container, or application that expects to find API keys and passwords as standard environment variables, you don\u0026rsquo;t need to store them in plain text .env files. SOPS has a dedicated command to decrypt secrets in memory and inject them directly into your application\u0026rsquo;s process:\nsops exec-env secrets.enc.yaml \u0026#39;npm run start\u0026#39; The npm run start command will launch with access to all variables defined in the decrypted file. As soon as the process exits, those variables disappear. No secrets are ever persisted to disk in plain text.\n2. Reading Structured Secrets in Shell Scripts If you only need to extract a specific value from an encrypted YAML file in an automation script, you can combine SOPS with yq (or use the native SOPS extractor):\n# Using the native SOPS extractor DB_PASS=$(sops -d --extract \u0026#39;[\u0026#34;database\u0026#34;][\u0026#34;password\u0026#34;]\u0026#39; secrets.enc.yaml) This is ideal for backup scripts or automated deploys over SSH, allowing you to fetch exactly the secret you need at execution time.\n3. Direct Integration in Your Code (Python) In programming languages, you can read SOPS encrypted files by calling the binary as a subprocess, returning a structured dictionary directly in memory.\nHere is a simple Python helper to load secrets without ever saving them to disk in readable format:\nimport json import subprocess def load_sops_secrets(filepath: str) -\u0026gt; dict: # Decrypt the file directly in memory as JSON result = subprocess.run( [\u0026#34;sops\u0026#34;, \u0026#34;-d\u0026#34;, \u0026#34;--output-type\u0026#34;, \u0026#34;json\u0026#34;, filepath], capture_output=True, text=True, check=True ) return json.loads(result.stdout) # Practical usage secrets = load_sops_secrets(\u0026#34;secrets.enc.yaml\u0026#34;) db_password = secrets[\u0026#34;database\u0026#34;][\u0026#34;password\u0026#34;] This approach ensures that even if the server is compromised and the disk is copied, the secrets remain secure (since the age key can be isolated with strict permissions under /etc/ or loaded only in the server\u0026rsquo;s key agent).\nConclusion: Real Security is the One You Actually Use One of the greatest lessons in systems administration is that security is inversely proportional to friction. If a security method requires 10 complex manual steps, people will find a shortcut—and that shortcut is usually a plain text password in an unprotected file.\nThe combination of SOPS + age shines because it removes the \u0026ldquo;security theater.\u0026rdquo; It allows you to maintain a 100% declarative, versioned, and secure infrastructure in Git without managing complex GPG keys or provisioning heavy secrets servers for small and medium environments.\nOnce you integrate this foundation into your automation routine, secrets management stops being an operational burden and becomes a natural, fluid, and transparent part of your development process.\nWhat about you? How do you manage secrets in your infrastructure today? Have you given age a shot, or are you still wrestling with GPG keys? Share your thoughts in the comments below.\n","date":"26/05/2026","lang":"en","tags":["security","devops","sops","age","gitops"],"title":"SOPS + age: Declarative, Secure Secrets Management Without GPG Headache","url":"https://devops.sarmento.org/en/posts/sops-and-age-secrets-management-in-practice/"},{"categories":["macOS","Linux"],"content":"At some point in the life of almost every sysadmin, there comes a slightly uncomfortable realization: too many secrets are scattered across the environment.\nA password inside a .env file here, a token buried in shell history there, a forgotten webhook inside a docker-compose.yml, an API key hardcoded into a “temporary” script that somehow survived for two years in production. None of those things seem catastrophic individually. The problem is that infrastructure rarely collapses because of one gigantic mistake; most of the time, it collapses under the accumulated weight of dozens of tiny operational shortcuts.\nAnd honestly, most credential leaks are not caused by cinematic hacker attacks. They happen because someone:\ncommitted the wrong file; backed up too much; exported a variable globally; shared a screenshot without masking sensitive information; left verbose logging enabled; reused an old configuration without reviewing its contents. The longer you work with infrastructure, the more you realize that secret management is not about building an impenetrable fortress. It is about reducing accidental exposure.\nThe problem with environment variables Environment variables are extremely useful. They simplify automation, application integration, CI/CD pipelines, and service configuration.\nThe problem starts when they stop being a tool and become the entire secret management strategy.\nThere is a massive difference between:\nMYSQL_PASSWORD=\u0026#34;$(pass show production/mysql)\u0026#34; ./backup.sh and:\nexport MYSQL_PASSWORD=\u0026#34;super-secret-password\u0026#34; In the first example, the secret exists temporarily for a specific process. In the second, it becomes part of the entire shell session — and potentially every child process spawned from it.\nDepending on the environment, variables may end up exposed in:\nprocess dumps; debugging tools; logs; troubleshooting sessions; support scripts; shell history; monitoring systems; crash reports. A surprising number of people treat .env files as if they were encrypted; they‘re not. They are plain text files with a socially accepted filename.\nThe macOS Keychain is better than many people think Within Linux circles, there is often a tendency to underestimate Apple’s ecosystem. But the macOS Keychain solves several operational problems in an unexpectedly elegant way.\nIt combines:\nencrypted storage; session integration; biometric unlock; per-application permissions; transparent desktop integration. Adding a secret from the terminal is straightforward:\nsecurity add-generic-password \\ -a \u0026#34;$USER\u0026#34; \\ -s \u0026#34;github-token\u0026#34; \\ -w \u0026#34;YOUR_TOKEN_HERE\u0026#34; Retrieving it later is just as simple:\nTOKEN=\u0026#34;$(security find-generic-password \\ -a \u0026#34;$USER\u0026#34; \\ -s \u0026#34;github-token\u0026#34; \\ -w)\u0026#34; That alone eliminates several common problems:\ntokens forgotten in files; permanent shell exports; duplicated credentials; manual copying between machines. What makes this especially interesting is that the Keychain rarely appears in modern DevOps discussions because the entire conversation tends to orbit around Kubernetes, Vault, and endless YAML files. Meanwhile, many people are operating smaller, more direct environments that look nothing like hyperscale cloud infrastructure.\nIn those scenarios, the Keychain solves a surprising amount of friction.\nLinux: freedom, fragmentation, and too many choices On Linux, the story changes completely.\nThere is no dominant equivalent to the Keychain. Instead, there is an entire ecosystem of partially overlapping tools:\nGNOME Keyring; KWallet; pass; gopass; sops; Vault; systemd secrets; Docker secrets; cloud-provider-specific solutions. That flexibility is powerful, but it also creates an inevitable side effect: many environments never develop a coherent secret management strategy.\nThe result usually looks something like this:\nhalf the secrets live inside .env files; another portion sits in Ansible Vault; some tokens are embedded in shell scripts; certificates are copied manually between servers; backups contain everything. After a few years, the infrastructure turns into operational archaeology.\nThe charm — and the suffering — of pass There is something almost irresistible about pass.\nThe idea is brilliantly simple:\neach secret is just a file; everything is encrypted with GPG; Git can version the repository; shell integration works beautifully. Example:\npass insert production/mysql Later:\nMYSQL_PASSWORD=\u0026#34;$(pass show production/mysql)\u0026#34; It is simple, auditable, and extremely Unix-like.\nThe problem is that pass inherits all of the emotional complexity of GPG itself.\nAnd any sysadmin who has ever had to:\nrotate keys; import keys onto new machines; explain trust levels; troubleshoot agent issues; recover a partially broken environment; already knows how quickly that pain escalates.\nEven so, I still think pass is an excellent solution for small and medium-sized environments.\nWhat changed significantly in recent years: sops and age One of the more interesting developments in recent years has been the growing popularity of sops, especially when combined with age.\nThe approach is different from pass.\nInstead of storing secrets individually, you can partially encrypt entire YAML, JSON, or TOML files.\nFor example:\nsops secrets.yaml Inside the file:\ndatabase: host: db.internal username: app password: ENC[AES256_GCM,data:...] This works extremely well with:\nAnsible; Terraform; Git repositories; declarative infrastructure; automation in general. And honestly, age is dramatically more pleasant to work with than GPG in most situations.\nNot because it is \u0026ldquo;magic,\u0026rdquo; but because it removes a large amount of operational friction.\nThe most common mistake: treating secrets like ordinary configuration This is probably the mistake I see most often — and, admittedly, one I make myself more than I would like.\nOver time, secrets stop being treated as sensitive information and slowly become just another piece of operational configuration.\nIt happens when:\ntokens end up inside templates; compose files accumulate passwords; backups include everything indiscriminately; scripts load permanent variables; files are copied between servers without review; temporary environments become permanent. Eventually, someone runs:\ngrep -R PASSWORD . and discovers an entire graveyard of old operational decisions.\nReal security vs. operational theater There is a lot of theater in security:\nBase64 presented as encryption. “Kubernetes Secrets” stored almost in plaintext. Passwords masked in CI but printed in verbose logs. Encrypted .env files whose keys live in the same repository. Sometimes an infrastructure appears secure simply because it generates enough complexity that nobody wants to question it anymore.\nBut operational security rarely comes from the most sophisticated tool in the stack. More often, it comes from:\nreduced surface area; predictability; simplicity; auditability; fewer places containing secrets. Tools help. Operational discipline helps more.\nWhat I try to do today These days, I try to follow a few relatively simple rules:\navoid permanent environment variable exports; reduce the number of copies of the same secret; keep tokens out of repositories; avoid hardcoded credentials in automation; treat backups as sensitive material; prefer simple and auditable solutions. I also try to avoid turning secret management into a technological religion.\nNot every environment needs Vault, not every server needs an entire cloud-native architecture just to store two API keys and a database password; sometimes a small, predictable, well-understood environment is safer than an enormous stack that nobody truly understands anymore.\nConclusion Secret management is less about hiding everything inside an impenetrable fortress and more about reducing unnecessary exposure, preventing accidental leaks, and avoiding the moment when operational convenience quietly turns into permanent technical debt.\nBecause in the real world, most security problems start in a much less dramatic way than conference presentations like to suggest. Usually, they begin with someone saying:\n— I’ll clean this up properly later.\n","date":"17/05/2026","lang":"en","tags":["secret-management","keychain","linux","macos","shell","security","devops","automation","privacy","backup"],"title":"Secret Management on macOS and Linux: a Practical-Theoretical Approach","url":"https://devops.sarmento.org/en/posts/secret-management-macos-linux/"},{"categories":["Static Sites"],"content":"In the post about Hugin I introduced the tool I use to generate tags and summaries for my Hugo blogs. Two weeks later, in the post about Munin, I showed its sibling: a second program that discovers and inserts internal links between posts using embeddings and an LLM.\nBoth worked well. Separately, they worked well.\nThe problem with having two programs In theory, splitting responsibilities between tools is good practice. In practice, the workflow for processing a new post looked like this: open Hugin, navigate to the post, generate tags, generate summary, close Hugin. Open Munin, wait for the embedding model to load, navigate to the same post, check existing links, generate link suggestions, apply. If you needed to fix a typo in the title that only showed up after reviewing the post in Hugin, close everything and open Pages CMS or vim.\nWith 5 new posts a week, this was a 20-minute ritual that could have been 5. The friction wasn\u0026rsquo;t in the features — it was in the context switching. Every time I left one program and entered the other, I lost the thread of what I was doing. And having to go to Pages just to fix a sentence or a title typo was the final insult.\nThe decision to unify There was no way around it: either I kept living with the friction or I merged everything. I chose to merge. Munin ceased to exist as a separate program, and all of its functionality was absorbed into Hugin. The result is a single command that does everything: tags, summaries, internal links, topic suggestions, and post editing.\nWhat used to be two programs with nearly identical interfaces but distinct functions became a single screen with every action available by keypress. You navigate to a post and everything is right there: t for tags, s for summary, i for incoming links, o for outgoing links, l to list and remove links, u for new topic suggestions, e to edit the post. No closing, no reopening, no relocating.\nWhat changed Built-in editor The most significant addition is the internal editor. Press e and a full-screen view opens with editable fields for each frontmatter field — title, date, description, slug, draft — and a TextArea with Markdown syntax highlighting for the post body. Tags are shown as read-only because there\u0026rsquo;s a dedicated t for that.\nSaves are atomic: writes to a temp file and renames, exactly how Munin already handled links. If the process dies mid-write, the original file stays intact. And if you exit without saving, a confirmation dialog prevents accidental losses.\nThis eliminated the need to go to Pages CMS or open vim just to fix a paragraph. Quick corrections happen right there, without leaving the flow.\nLoading indicator The subtle per-row spinners were replaced by a modal with a semi-transparent backdrop that appears during LLM operations. There\u0026rsquo;s no way to miss that the program is working. The message changes with the operation: \u0026ldquo;Generating tags\u0026hellip;\u0026rdquo;, \u0026ldquo;Finding outgoing links\u0026hellip;\u0026rdquo;, \u0026ldquo;Suggesting new topics\u0026hellip;\u0026rdquo;.\nPer-project settings Each blog now has its own configuration file with parameters that affect LLM behavior. Accessible via the p key:\nSummary words — the target word count sent in the prompt. My personal blog uses 28, the technical one uses 20. Summary style — a tone instruction sent directly to the LLM. Instead of choosing between presets like \u0026ldquo;formal\u0026rdquo; or \u0026ldquo;casual\u0026rdquo;, you write exactly what you want: \u0026ldquo;Write it engaging, causing on the reader the wish to read the full post.\u0026rdquo; Words per link — controls internal link density. A blog with 400 posts can afford to be liberal (150 words per link). One with 18 needs to be more conservative (400 or more). Project settings take priority over global defaults. If you don\u0026rsquo;t configure anything, the values from links.toml still apply.\nDrafts excluded from embeddings Draft posts no longer show up as candidates for internal links. And if you change a published post to draft, it\u0026rsquo;s automatically removed from the embedding index on the next run. This prevents link suggestions pointing to posts that don\u0026rsquo;t exist on the published site.\nMore reliable cache Two issues that bit me in production were fixed. First: deleted posts lingered as ghosts in the embedding cache, generating link suggestions to pages that no longer existed. Now find_similar checks whether the file still exists on disk before returning a result.\nSecond: URLs became stale if the Hugo permalink configuration changed. Hugin now re-resolves URLs for all cache entries on every startup, without needing to clear and rebuild everything.\nPersistence across sessions The Textual theme (the color palette you pick with Ctrl+P) now persists between runs. And the post that was selected when you quit is automatically restored next time. Small comforts that make a difference when you use the tool every day.\nWhat stayed the same The core architecture didn\u0026rsquo;t change. Hugin is still a Python TUI that talks to any OpenAI-compatible endpoint. Engines, API keys, and model selection work exactly as before. The tag prompts and normalizer are the same. The embedding system is still local, no tokens spent. Link insertion still respects Markdown protected zones.\nIf you were using Hugin and Munin separately, the transition is simple: update the repo and reinstall. The munin command no longer exists — everything is hugin now. The munin.toml config file is still read as a fallback if links.toml doesn\u0026rsquo;t exist, so nothing breaks.\nCurrent workflow My flow for processing a new post is now LOST:\nL (List) — check what links already exist in the post O (Outgoing) — ask for new link suggestions S (Summary) — generate the summary T (Tags) — generate tags All in one program, no window switching, no reloading, no losing context. If I need to fix something in the text, e opens the editor right there.\nCode Hugin is open source:\nRepository: github.com/janiosarmento/hugin\n","date":"18/04/2026","lang":"en","tags":["hugo","blog","open-source","tags","internal-links","ai-assistant","self-hosting","automation","markdown"],"title":"Hugin on steroids: tags, links and editing in one TUI","url":"https://devops.sarmento.org/en/posts/hugin-on-steroids-tags-links-and-editing-in-one-tui/"},{"categories":["Static Sites"],"content":"Anyone who maintains a static blog with Hugo knows there are two tasks nobody enjoys: classifying posts with tags and writing meta descriptions. These are the things you skip when publishing because the post is already done, the deploy is set up, and choosing between \u0026ldquo;selfhosted\u0026rdquo; and \u0026ldquo;self-hosted\u0026rdquo; isn\u0026rsquo;t exactly the best use of your time. The result is predictable: posts without tags, empty descriptions or ones copied from the first paragraph, and a taxonomy that hurts more than it helps.\nI was in this situation with two blogs — one with nearly 400 posts, the other growing fast. Inconsistent tags, posts with no classification at all, generic descriptions. AI tools would solve part of the problem, but none fit the workflow I wanted: something that would read the posts, suggest tags and summaries, but let me approve everything before touching any files. No surprises, no automatic commits, no invented tags I\u0026rsquo;d never use.\nSo I built Hugin.\nWhat it is Hugin is a Python TUI (terminal user interface) that opens a directory of Hugo posts, lists them all by publication date, and offers two main operations: generate tags and generate summaries. In both cases, the post content is sent to an LLM, the response is normalized and presented for review before touching the file.\nThe name comes from Huginn, one of Odin\u0026rsquo;s ravens in Norse mythology — the raven of thought, who flies across the world gathering information and reports back to its master. It also rhymes with Hugo, which is no coincidence.\nThe tag problem The most annoying part of keeping tags consistent in a blog isn\u0026rsquo;t creating new tags — it\u0026rsquo;s remembering which ones already exist. If you have 400 posts and 60 unique tags, it\u0026rsquo;s only a matter of time before \u0026ldquo;selfhosted\u0026rdquo; shows up in one post and \u0026ldquo;self-hosted\u0026rdquo; in another. Or \u0026ldquo;automatization\u0026rdquo; and \u0026ldquo;automation\u0026rdquo; coexisting as if they were different concepts.\nHugin solves this in two ways. First, it collects all existing tags from the blog and sends them to the LLM sorted by usage frequency, with explicit instructions to prefer tags that already exist. Second, it passes each generated tag through a normalizer that applies lowercase, replaces spaces with hyphens, removes articles, and deduplicates against the existing pool. If the LLM suggests \u0026ldquo;The Docker\u0026rdquo; for a post, the normalizer turns it into \u0026ldquo;docker\u0026rdquo; — which is probably already in the pool.\nNew tags appear marked with a visual indicator in the interface. If the LLM suggested something that doesn\u0026rsquo;t exist in the blog, you know before applying it. And if you want to add tags manually — because sometimes the LLM simply doesn\u0026rsquo;t suggest the obvious — there\u0026rsquo;s a text field for that.\nThe summary problem Meta descriptions are those 150-character texts that appear in search results. Everyone knows they matter, nobody enjoys writing them. The typical result is an empty description (Hugo uses the first characters of the post) or a generic sentence that says nothing about the content.\nHugin generates summaries between 140 and 160 characters, in the post\u0026rsquo;s language, with the persona of a blogger writing about their own work — not an SEO copywriter. In other words: no \u0026ldquo;Discover how\u0026hellip;\u0026rdquo; or \u0026ldquo;Learn to\u0026hellip;\u0026rdquo;. If the summary is too long, the LLM is automatically called again to shorten it. The result appears with a character count beside it, and is only saved if you approve.\nTag management As the blog grows, the taxonomy needs maintenance. Hugin has a dedicated screen for this: a list of all tags sorted by frequency, with rename, merge, and delete operations. Merge is particularly useful — select the source tag, choose the destination, and all posts are updated at once. If you have \u0026ldquo;linux\u0026rdquo; in 20 posts and \u0026ldquo;gnu-linux\u0026rdquo; in 3, it\u0026rsquo;s resolved in two clicks.\nModel agnostic Hugin doesn\u0026rsquo;t depend on any specific AI provider. Any OpenAI-compatible API endpoint works: Cerebras, Groq, DeepSeek, OpenAI, LM Studio, Ollama. Engines are registered in a simple TOML file, and engine and model selection is done directly in the interface — including automatic listing of available models on the endpoint.\nIn practice, I\u0026rsquo;ve been using Cerebras for most posts (fast and cheap) and LM Studio with local Gemma for testing. Switching between them is one keystroke.\nHow it works in practice You open Hugin pointing to the posts directory:\nhugin ~/blog/content/posts The interface shows all posts in a navigable table. Select one, press t for tags or s for summary. A spinner appears while the LLM works. When the response arrives, suggestions appear with checkboxes — uncheck what you don\u0026rsquo;t want, check what you do, apply. The front matter is updated, the file is saved, and you move on to the next post.\nNo tokens are spent until you ask. No changes are made until you approve.\nStack For those interested in the technical side: Python 3.11+, Textual for the TUI, Click for the CLI, httpx for async LLM calls, python-frontmatter for reading and writing YAML. No database, no daemon, no heavy dependencies.\nCode Hugin is open source and available on GitHub. If you maintain a Hugo blog and are tired of classifying posts manually, it might solve your problem too.\nRepository: github.com/janiosarmento/hugin\n","date":"17/04/2026","lang":"en","tags":["hugo","blog","tags","static-site","automation","ai-assistant"],"title":"Hugin: tags and summaries for Hugo with AI","url":"https://devops.sarmento.org/en/posts/hugin-tags-and-summaries-for-hugo-with-ai/"},{"categories":["Linux"],"content":"If you\u0026rsquo;ve been using Ubuntu for a while, you\u0026rsquo;ve probably noticed that the /var/lib/snapd directory grows silently and steadily. The reason isn\u0026rsquo;t the Snap packages you\u0026rsquo;ve installed — it\u0026rsquo;s the old copies the system automatically keeps every time one of those packages is updated. On a system with dozens of snaps, it\u0026rsquo;s common to find 5, 8, or even more gigabytes occupied by revisions you\u0026rsquo;ll never use. This issue is especially troublesome on smaller partitions, SSDs with limited space, or VMs with tight disk capacity. The good news is that identifying and removing this excess takes just a few minutes, as long as you know where to look and what not to delete.\nWhat are Snap reviews and why do they exist Snapd operates on the concept of immutable revisions. Each time a snap receives an update, the previous version is not deleted — it is marked as disabled and remains on disk, intact and ready to be reactivated if the new version encounters issues. This means the system always keeps at least two copies of each installed snap: the active one and the previous one. The mechanism is analogous to what distributions like NixOS and Fedora Silverblue do with the entire system, but applied at the individual package level.\nThis behavior is controlled by the refresh.retain configuration, which defines how many revisions snapd should keep per snap. The default value is 2, which is also the minimum — users cannot configure the system to keep only the active version without any backup. Canonical enforces this minimum because rollback is one of the fundamental guarantees of the Snap architecture, and allowing users to eliminate all redundancy would compromise this promise.\nThe scenario where this makes the most sense is in IoT devices, edge computing, and industrial infrastructure. In these environments, snaps are updated remotely and autonomously, and the ability to roll back to a previous version without on-site human intervention is part of the platform\u0026rsquo;s business appeal. If an edge gateway in a factory receives a faulty update at 3 a.m., automatic rollback resolves the issue before anyone needs to get out of bed. On your desktop or laptop, however, the practical benefit of this safety net is much smaller — and the disk space cost may be hard to justify when storage is tight.\nDiagnosis: how much space is being used Before removing revisions, it\u0026rsquo;s worth understanding the scale of the problem on your specific system. The amount of wasted space varies significantly depending on how many snaps you have installed and how frequently they\u0026rsquo;re updated — a system with a half-dozen lightweight snaps might be wasting only a few hundred megabytes, while one with Firefox, Chromium, LibreOffice, and several GNOME runtimes could easily exceed 5 GB in inactive revisions.\nListing all revisions The starting point is the snap list --all command, which shows all installed snaps along with their revisions, including the disabled ones:\nsudo snap list --all The output includes columns such as name, version, revision number, and notes. The information of interest here is the \u0026ldquo;Notes\u0026rdquo; column — older revisions appear marked as disabled. Each row with this marking represents an inactive copy occupying disk space. If you\u0026rsquo;d like a cleaner view, showing only the disabled revisions:\nsnap list --all | grep disabled The command\u0026rsquo;s output length shows the problem\u0026rsquo;s scale; every line is a candidate for removal.\nMeasuring actual consumption The snap list command doesn\u0026rsquo;t show the size of each revision, so to get a clear picture of the actual disk impact, you need to check directly in the filesystem. The quickest way is:\nsudo du -sh /var/lib/snapd This value includes everything managed by snapd — active and inactive revisions, caches, and metadata. For a breakdown per individual snap, navigate to the directory where the .snap files are stored:\nsudo du -sh /var/lib/snapd/snaps/* Each file in this directory corresponds to a specific revision (the filename follows the pattern name_revision.snap), and you can cross-reference the revision numbers with the output of snap list --all to identify which ones are active and which are candidates for removal. If you prefer a graphical view, the GNOME Disk Usage Analyser allows you to navigate to /var/lib/snapd/snaps and quickly see which files are larger and older — combining size and date helps make more confident decisions about what to remove.\nManual cleanup: removing revisions one by one The safest approach to clean up disabled revisions is to remove them individually, reviewing each one before executing the command. The process is simple but requires attention — snap remove with the --revision flag does not ask for confirmation before taking action.\nThe command follows this format:\nsudo snap remove --revision=NÚMERO nome-do-snap For example, if snap list --all shows that Firefox has revision 5678 marked as disabled, the command would be:\nsudo snap remove --revision=5678 firefox Removal is immediate and silent. If the revision number or snap name is incorrect, the command fails without causing harm — but if you accidentally specify the active revision, snapd refuses to remove it, so there\u0026rsquo;s no risk of accidentally breaking a snap in use.\nThe workflow that works best in practice is to keep two terminals side by side: one showing the output of snap list --all | grep disabled for reference, and the other where you type the removal commands. This prevents typos in revision numbers and allows you to mentally check off the lines you\u0026rsquo;ve already processed. On a system with just a few disabled snaps, the entire process takes less than two minutes.\nThe advantage of this approach over an automated script is granular control. You can decide, for example, to keep the previous revision of snapd or core22 as an extra precaution, while confidently removing old revisions of snap-store or gtk-common-themes. Not every snap has the same level of criticality, and manual removal allows you to handle each case individually.\nQuick cleanup: the one-liner to remove everything at once If you already understand what is being removed and don\u0026rsquo;t need to evaluate each snap individually, a one-line loop handles the entire task at once. This is the approach most tutorials on the internet recommend, and it works well — as long as you know what you\u0026rsquo;re doing.\nThe loop in one line The command combines snap list --all with text filters to extract the names and revisions of all disabled snaps and pass them directly to snap remove.\nsnap list --all \\ | awk \u0026#39;/disabled/{print $1, $3}\u0026#39; \\ | while read name rev; do sudo snap remove \u0026#34;$name\u0026#34; --revision=\u0026#34;$rev\u0026#34; done The awk command filters lines containing \u0026ldquo;disabled\u0026rdquo; and extracts the first field (snap name) and the third field (revision number). The while read loop iterates over each pair and performs the removal. The process takes from a few seconds to just over a minute, depending on the number of accumulated revisions, and the terminal output shows each snap being removed as the loop progresses.\nVariation with dry-run Oh snap, remove doesn\u0026rsquo;t have a native dry-run flag, but it\u0026rsquo;s easy to simulate one by replacing the execution with an echo:\nsnap list --all \\ | awk \u0026#39;/disabled/{print $1, $3}\u0026#39; \\ | while read name rev; do echo \u0026#34;snap remove $name --revision=$rev\u0026#34; done The output shows exactly which commands would be executed, without making any changes. This allows you to review the full list before committing to any action. If everything looks correct, simply run the actual version. If a specific snap appears in the list and you\u0026rsquo;d prefer to keep it — such as snapd or a kernel snap — note the name and add an additional filter to awk, for example:\nsnap list --all \\ | awk \u0026#39;/disabled/ \u0026amp;\u0026amp; !/snapd/ \u0026amp;\u0026amp; !/core/{print $1, $3}\u0026#39; \\ | while read name rev; do sudo snap remove \u0026#34;$name\u0026#34; --revision=\u0026#34;$rev\u0026#34; done This variation excludes from removal any line containing \u0026ldquo;snapd\u0026rdquo; or \u0026ldquo;core\u0026rdquo;, preserving backup revisions of the system\u0026rsquo;s most sensitive components while cleaning up everything else.\nRisks and precautions Removing disabled revisions from Snap isn\u0026rsquo;t a destructive operation in the classical sense — you\u0026rsquo;re not erasing personal data or uninstalling programs. However, it\u0026rsquo;s not an action without consequences, and it\u0026rsquo;s worth understanding what you\u0026rsquo;re giving up before you start cleaning everything up.\nNo rollback available The most direct effect of removal is the loss of rollback capability. If the active version of a snap has a bug after the next update, snapd will not have a previous revision to which it can revert — the command snap revert snap-name will fail because there is no longer a backup copy on disk. In practice, this means your only options when facing a problematic update are to wait for an upstream fix or to reinstall the snap entirely, losing any local configurations that aren\u0026rsquo;t saved elsewhere.\nFor most desktop snaps — text editors, messaging clients, various utilities — the risk is low. Problematic updates in these packages are usually more annoying than disruptive, and a fix typically appears within a few days. The situation changes when it comes to snaps that affect the system\u0026rsquo;s basic operation.\nSystem and Kernel Snaps The snaps snapd, core, core20, core22, core24, and any kernel snaps form the base on which all other snaps run. A faulty update to any of them could prevent other snaps from starting or, in more severe cases, disrupt the boot process on systems that rely on snaps for startup components. Removing the backup revision of these packages eliminates the safety net precisely where it\u0026rsquo;s most needed.\nThe recommendation is simple: if you\u0026rsquo;re using the one-liner for bulk cleanup, exclude these snaps from the filter (as shown in the variation with !/snapd/ \u0026amp;\u0026amp; !/core/ in the previous section). If you\u0026rsquo;re removing them manually, skip the lines corresponding to these packages. The space they occupy rarely justifies the risk.\nIt\u0026rsquo;s not permanent Cleaning up disabled revisions solves the space issue at the moment it\u0026rsquo;s performed, but it doesn\u0026rsquo;t change snapd\u0026rsquo;s behavior going forward. The next time any installed snap receives an update, the current version will be retained as a backup and the cycle starts again. Depending on how many snaps you have and how frequently they\u0026rsquo;re updated, the accumulation can return to uncomfortable levels within a few weeks or months.\nThere is no native way to disable review retention or to schedule automatic cleaning. If disk space is a recurring concern on your system, this removal will become a periodic task—something to include in your maintenance routine alongside apt autoremove and cleaning the journalctl cache. If you want to automate this cleaning, it is worth considering systemd timers instead of cron — or launchd if you are on macOS.\nAlternative: adjusting the refresh.retain Before resorting to manual removal or the one-liner, it\u0026rsquo;s worth checking whether your system is already configured to retain the minimum number of revisions possible. The snapd setting refresh.retain defines how many revisions of each snap are kept on disk, and the default value on older installations may be 3 — meaning the system keeps two backup copies instead of one.\nTo check the current value:\nsudo snap get system refresh.retain If the output shows a number greater than 2, you can reduce it to the minimum with:\nsudo snap set system refresh.retain=2 The change is immediate and affects the behavior of all future updates. From this point onward, snapd will keep at most one backup revision per snap, automatically discarding the oldest one whenever a new update arrives. Excess revisions already present on disk are not removed retroactively — to clean up what has already accumulated, you still need to use the commands from previous sections.\nThe value 2 is the absolute minimum for this setting. Attempting to set refresh.retain=1 results in an error, as Canonical considers keeping at least one backup a non-negotiable requirement of the Snap architecture. There is no hidden flag, environment variable, or documented workaround to bypass this limitation. If you truly need to keep only a single copy of each snap (with no backups), the only way to achieve that is by manually removing inactive revisions after each update cycle — which brings us back to the commands already covered in this post.\n","date":"17/04/2026","lang":"en","tags":["disk-space-management","snap","linux"],"title":"Cleaning up old Snap revisions to free up space on Ubuntu","url":"https://devops.sarmento.org/en/posts/cleaning-up-old-snap-revisions-to-free-up-space-on-ubuntu/"},{"categories":["Linux"],"content":"In the previous post, macOS\u0026rsquo;s launchd watched files and directories with WatchPaths to fire scripts automatically when something changed. The model is reactive — instead of running a backup every hour or a conversion every five minutes, the system watches the path on disk and only runs the job when it detects an actual modification. No polling, no waste, no vulnerability window between the change and the action.\nLinux has the same capability, but implemented differently and with more options. systemd offers path units — .path files that monitor filesystem paths and automatically activate an associated service when the condition is met. It is the direct equivalent of launchd\u0026rsquo;s WatchPaths, with the same declarative philosophy: you describe what to watch in a configuration file, the system handles the rest. For anyone working on servers or desktops with systemd, which at this point means practically every mainstream distribution, path units are the right tool.\nBut not everyone has systemd available. Minimal containers, distributions like Alpine or Void Linux, shared environments where the user has no control over system services, legacy machines — there are legitimate scenarios where systemd is not available or where the user simply cannot create units. For those cases, inotifywait from the inotify-tools package solves the problem directly in the shell, using the same kernel notification infrastructure (inotify) that systemd uses under the hood, but without needing a daemon, root, or a configuration file.\nThis post applies the same two scenarios from the previous post — reactive SQLite backup and automatic image optimization — on Linux, first with systemd path units and then with inotifywait. The image conversion script is already published; the focus here is on the trigger mechanics and configuration file construction.\nsystemd path units: the Linux version of WatchPaths Anyone who read the post about systemd timers already knows the pattern: systemd divides responsibilities between units of different types, and each type handles one aspect of the job. A timer (.timer) defines when a service (.service) should run. A path (.path) does the same thing, but the trigger is not the clock — it is the filesystem. The .path watches, the .service executes. Two files working together, the same way a timer and service work together for time-based scheduling.\nThe separation may seem bureaucratic compared to launchd, where a single plist contains both the trigger (WatchPaths) and the command to execute (ProgramArguments). In practice, the division brings the same advantage that already appeared with timers: the service can be tested independently of the path. You run systemctl start my-backup.service to verify the script works, and only then activate my-backup.path to put the monitoring into production. If the service fails, the problem is the script, not the trigger. If the path does not fire, the problem is the monitoring, not the script. Each piece can be diagnosed in isolation, which on systems that run without direct supervision — headless servers, VPSes, containers — makes a real difference when something breaks at three in the morning.\nChange detection on Linux uses the kernel\u0026rsquo;s inotify subsystem, which is the equivalent of kqueue on macOS. inotify has been around since kernel 2.6.13 (2005, coincidentally the same year launchd appeared on macOS) and is the standard infrastructure for filesystem event notification on Linux. Tools like tail -f, Webpack\u0026rsquo;s hot-reload, Python\u0026rsquo;s watchdog, and systemd itself use inotify under the hood. There is no polling involved — the kernel notifies the watching process when the event occurs, with negligible latency and near-zero resource consumption while nothing changes.\nHow a .path + .service works PathChanged, PathModified, and PathExists launchd has a single key — WatchPaths — that fires on any modification. systemd is more granular and offers three different directives for path monitoring, each with its own semantics.\nPathModified fires when the file\u0026rsquo;s content is changed — that is, when a write actually changes data in the file. It is the closest equivalent to launchd\u0026rsquo;s WatchPaths for monitoring individual files. If what matters is knowing that the SQLite database received new transactions or that a configuration file was edited, PathModified is the right directive.\nPathChanged fires when the file is closed after being modified. The difference from PathModified is temporal: while PathModified can fire during the write (on each buffer flush, for example), PathChanged waits for the file to be closed before firing. For backup scripts, this difference matters — firing the backup while the file is still being written can result in an inconsistent copy. In practice, PathChanged is the safer choice for most cases because it guarantees the write is complete before activating the service.\nPathExists fires when the specified path comes into existence. It does not monitor modifications — only creation. If the file already exists when the path unit is activated, the service fires immediately. It is useful for semaphore-type scenarios: a process creates a signal file when it finishes its work, and the path unit detects the presence of that file to start the next step. For this post\u0026rsquo;s scenarios, PathExists is not the right tool.\nThere is also PathExistsGlob, which works like PathExists but accepts glob patterns — *.csv, backup-*.sql, etc. It fires when any file matching the pattern comes into existence in the directory. It seems tempting for the image optimization scenario (monitoring *.png and *.jpg), but in practice it has the same limitation as PathExists: it only detects creation, not subsequent modification. If the file is created empty and filled afterward — as some editors do — the service may fire before the content is ready.\nAll three directives can monitor both files and directories. When the path is a directory, PathModified and PathChanged fire when the directory structure changes — creation, removal, or renaming of files inside it. The behavior is analogous to launchd\u0026rsquo;s WatchPaths with directories, including the same limitation: monitoring is not recursive. Subfolders are not watched automatically; each relevant path needs its own directive.\nMultiple directives can coexist in the same .path. A single path unit can have a PathChanged for one file and a PathModified for another, and the associated service fires when any condition is met. This flexibility does not exist in launchd, where each plist has a single WatchPaths — although the WatchPaths array accepts multiple paths, the type of event monitored is always the same for all of them.\nThe relationship between .path and .service The link between the .path and the .service is by naming convention: a path unit called my-backup.path automatically activates the service my-backup.service, without needing any explicit directive to connect the two. If for some reason the service has a different name, the Unit= directive in the [Path] section allows specifying which service to activate, but in practice keeping the same name is simpler and more readable.\nWhen the path unit detects a change, it activates the associated service — which runs, executes the script, and terminates. The path unit continues watching. On the next change, the service is activated again. The cycle repeats indefinitely as long as the path unit is enabled. It is exactly the same model as launchd: the trigger watches, the script executes and terminates, the trigger continues watching.\nOne detail that differs from launchd: if the service is still running when a new change is detected, systemd does not queue a second execution. The change is registered, but the service is not activated again until the current execution finishes. In practice, this means that if the backup script takes two minutes and the database is modified three times during that period, the service runs once when the current execution finishes — not three times. For scripts that sweep the entire directory on each execution (like the image optimizer), this behavior is desirable. For scripts that process a single event per execution, it means intermediate modifications may be \u0026ldquo;grouped\u0026rdquo; into a single activation.\nThe minimal form of the two files together is:\n~/.config/systemd/user/my-backup.path:\n[Path] PathChanged=/home/janio/data/file.db [Install] WantedBy=default.target ~/.config/systemd/user/my-backup.service:\n[Service] ExecStart=/home/janio/bin/my-script.sh Two files, four lines of configuration total (not counting the [Install] section). The .path says what to watch; the .service says what to run. Activation is done with a single command:\nsystemctl --user enable --now my-backup.path The --user is the detail that makes everything work without root, and deserves its own section.\nUser units: no root, no problem All units shown in this post live in ~/.config/systemd/user/ and are managed with systemctl --user. They do not need root to create, do not need root to activate, and run with the logged-in user\u0026rsquo;s permissions. It is the direct equivalent of LaunchAgents in ~/Library/LaunchAgents/ on macOS — personal automation, without escalating privileges, without touching the system configuration.\nUser units have, by default, one important limitation: they only run while the user has an active session. If the user logs out, the units stop. On desktops with a permanent graphical login this is rarely a problem, but on servers accessed via SSH it makes a difference. The solution is loginctl enable-linger, which allows the user\u0026rsquo;s units to keep running even without an active session. On macOS, this problem does not exist — personal LaunchAgents remain active while the user is logged into the graphical session, and the concept of \u0026ldquo;logout\u0026rdquo; on a personal Mac is rare enough not to be a practical concern.\nFor this post\u0026rsquo;s scenarios, user units are the right choice. The SQLite database is a user file, the images belong to the user, the scripts run in the user\u0026rsquo;s context, and the backup goes to a remote configured in the user\u0026rsquo;s rclone. Nothing here needs privileged access, and running as root would be unnecessary overkill — as well as a risk, since any bug in the script would have permission to damage the entire system rather than just the user\u0026rsquo;s files.\nScenario 1: Reactive SQLite backup The .path ~/.config/systemd/user/fuqu-backup.path:\n[Path] PathChanged=/home/janio/.local/share/fuqu/fuqu.db [Install] WantedBy=default.target One directive, one path. PathChanged instead of PathModified is deliberate: since SQLite in WAL mode makes multiple sequential writes (the WAL grows, the checkpoint consolidates), PathModified could fire the service multiple times during a single write operation. PathChanged waits for the file to be closed, which in practice means the service is only activated after the transaction finishes and the database is in a consistent state.\nIn the previous post about launchd, WatchPaths pointed at the fuqu.db file rather than the directory, because monitoring the directory might not capture writes made directly to the file by SQLite. The same reasoning applies here: PathChanged points at the database itself, not at ~/.local/share/fuqu/.\nThe .service ~/.config/systemd/user/fuqu-backup.service:\n[Service] Type=oneshot ExecStart=/home/janio/bin/fuqu-backup.sh Environment=PATH=/usr/local/bin:/usr/bin:/bin Type=oneshot tells systemd that the service runs a task and terminates — it is not a daemon that stays running. Without this directive, systemd assumes Type=simple and expects the process to remain active; when the script finishes, systemd would interpret the exit as a failure. oneshot is the correct type for backup scripts, conversions, syncs, and any job that runs, does its work, and exits.\nEnvironment=PATH=... solves the same problem as EnvironmentVariables in the launchd plist: systemd does not inherit the user\u0026rsquo;s shell PATH. If rclone was installed in /usr/local/bin or via snap in /snap/bin, the path needs to be explicit. An alternative is to use rclone\u0026rsquo;s absolute path directly in the script, but defining the PATH in the service keeps the script portable across machines where the binary may be in different locations.\nLogging, unlike launchd where we needed to declare StandardOutPath and StandardErrorPath in the plist, comes for free. systemd captures stdout and stderr automatically and directs them to the journal. To see the output of the last backup:\njournalctl --user -u fuqu-backup.service -n 50 To follow in real time while testing:\njournalctl --user -u fuqu-backup.service -f No log file to rotate, no disk silently filling up with months of accumulated output. The journal handles retention and space automatically — one of the concrete advantages of systemd over launchd\u0026rsquo;s manual log model.\nActivation of both files is a single command:\nsystemctl --user enable --now fuqu-backup.path enable registers the path unit to start automatically on login. --now activates immediately without waiting for the next login. From this point on, any modification to fuqu.db fires fuqu-backup.service. To verify that monitoring is active:\nsystemctl --user status fuqu-backup.path Throttle: why the script still needs to protect itself systemd does not have an automatic throttle equivalent to launchd\u0026rsquo;s 10-second interval. If the database is modified, the service runs. If the database is modified again one second later and the service has already finished, it runs again. There is no minimum forced interval between executions.\nThis makes the throttle in the script even more necessary than on macOS. The mechanism is the same described in the previous post: the script writes a timestamp to a control file after each backup, and on the next execution checks whether the minimum interval has passed before doing any work. If it hasn\u0026rsquo;t, it exits with code 0 and systemd records the execution as successful.\nsystemd has a RateLimitIntervalSec directive combined with RateLimitBurst in the [Unit] section that could, in theory, limit the activation frequency. But these directives control systemd\u0026rsquo;s own rate limit for service activations — when the limit is reached, systemd temporarily disables the path unit, which means modifications during that period are silently ignored. It is not a throttle with a guarantee of eventual execution; it is a circuit breaker that discards events. For a backup where no modification should be lost, the in-script throttle is the correct approach: the execution happens, checks that it is too soon, and exits cleanly — but the path unit remains active and ready to fire on the next change.\nScenario 2: Automatic image optimization The .path ~/.config/systemd/user/image-optimizer.path:\n[Path] PathChanged=/home/janio/Pictures/optimize [Install] WantedBy=default.target Here PathChanged points at a directory, not a file. The behavior is as expected: systemd fires the service when the directory structure changes — a file created, removed, or renamed. Saving a PNG to ~/Pictures/optimize/ is a file creation, which changes the directory, which fires the path unit.\nThe temptation to use PathExistsGlob with patterns like *.png and *.jpg comes up naturally here, but it does not solve the problem. PathExistsGlob fires when a file matching the pattern comes into existence, and fires only once — after the service runs, the path unit does not react to new files matching the same glob until it is restarted. It is a directive designed for semaphore scenarios (\u0026ldquo;wait until this file appears\u0026rdquo;), not for continuous monitoring. PathChanged on the directory is the correct choice for a flow where images can arrive at any time and in any quantity.\nThe .service ~/.config/systemd/user/image-optimizer.service:\n[Service] Type=oneshot ExecStart=/home/janio/bin/optimize-images.sh Environment=PATH=/usr/local/bin:/usr/bin:/bin The structure is identical to the backup service. Type=oneshot because the script processes images and terminates. The PATH includes /usr/local/bin where cwebp and avifenc may be — on Linux, unlike macOS with Homebrew in /opt/homebrew/bin, these packages typically come from the distribution\u0026rsquo;s package manager and install to /usr/bin or /usr/local/bin. On Debian-based distributions, the packages are webp and libavif-bin:\nsudo apt install webp libavif-bin Logging follows the same model: stdout and stderr go to the journal automatically. To check the result of a conversion:\njournalctl --user -u image-optimizer.service -n 20 Activation:\nsystemctl --user enable --now image-optimizer.path From this point on, any PNG or JPG saved to ~/Pictures/optimize/ fires the conversion script.\nThe same care with loops The loop problem described in the launchd post applies equally here, with the same cause and the same solution. If the script converts photo.png to photo.avif inside the same monitored directory, the creation of the .avif is a directory modification, which fires the path unit, which runs the script again. The script needs to filter by extension — process only .png, .jpg, and .jpeg, ignore everything else — or the separation into two directories (input and output) eliminates the problem at its root.\nOn systemd, there is an aggravating factor that launchd does not have: since there is no automatic 10-second throttle between executions, a loop caused by a missing filter can be more aggressive. The script creates the .avif, the path unit fires immediately, the script runs again, finds nothing to process (if the filter is correct) and exits. But if the filter is not correct and the script tries to reprocess the .avif, the sequence repeats with no external brake until systemd\u0026rsquo;s default RateLimitBurst intervenes and disables the path unit — which stops the loop but also kills legitimate monitoring.\nThe correct protection is twofold: the extension filter in the script ensures the execution ends without side effects when there is no real work to do, and a quick exit 0 at the beginning of the script when find returns no eligible files prevents systemd from even logging significant activity in the journal. The path unit fires, the script looks at the directory, finds no PNGs or JPGs, and exits in milliseconds. The cost of an empty execution is negligible; the cost of an unprotected loop can be a disabled path unit and images that stop being converted without warning.\nFor those without systemd: inotifywait What it is and where it comes from systemd path units and launchd\u0026rsquo;s WatchPaths are declarative abstractions over kernel mechanisms — inotify on Linux, kqueue on macOS. They hide the complexity behind configuration files and manage the process lifecycle automatically. But the abstraction requires the corresponding init system. On machines without systemd — minimal Docker containers, Alpine Linux, distributions using OpenRC or runit, shared servers where the user does not control services — path units do not exist.\ninotifywait is the way to access the kernel\u0026rsquo;s inotify directly from the shell, without intermediaries. It is part of the inotify-tools package, available in the repositories of virtually every Linux distribution. On Debian and Ubuntu:\nsudo apt install inotify-tools On Alpine:\napk add inotify-tools inotifywait blocks until an event occurs on the monitored path and then prints the event and exits — or, with the -m (monitor) flag, keeps running and printing events indefinitely. It is not a daemon, does not need a configuration file, does not need root. It is a command that watches and reports, and the reaction logic is up to whoever consumes its output — typically a while read in a bash script.\nEvent granularity is greater than anything available in launchd or systemd path units. inotify distinguishes between create, modify, close_write, delete, moved_to, moved_from, attrib, and more than a dozen other event types. inotifywait allows filtering by any combination of them with the -e flag. Where systemd\u0026rsquo;s PathChanged groups several events into a \u0026ldquo;the file was closed after modification\u0026rdquo; semantic, inotifywait lets you choose exactly which events matter — and ignore the rest.\nScenario 1 with inotifywait For the reactive SQLite backup, inotifywait monitors the fuqu.db file and fires the backup script when the file is modified and closed:\ninotifywait -m -e close_write /home/janio/.local/share/fuqu/fuqu.db | while read dir event file; do /home/janio/bin/fuqu-backup.sh done The close_write event is the right choice here — it fires when the file is closed after being opened for writing. It is the functional equivalent of systemd\u0026rsquo;s PathChanged: it guarantees the SQLite transaction finished before starting the backup. Using modify instead of close_write would fire the script on each buffer flush, potentially multiple times during a single write operation.\nThe command blocks in the terminal. To run in the background persistently, the options are putting it inside a script and running with nohup, inside a tmux or screen session, or — ironically — inside a systemd service or a supervisor like runit or s6. Without a supervisor, if the process dies (OOM, accidental kill, terminal crash), monitoring stops and nobody notices. This is the fundamental disadvantage of inotifywait compared to launchd and systemd: it does the monitoring, but does not take care of itself.\nThe throttle works the same way as in the previous scenarios — the backup script checks the timestamp of the last backup before executing. The difference is that with inotifywait the throttle is even more important, because there is no external mechanism limiting the call frequency. Every close_write on the database fires a script invocation, and during active FUQU use that can mean dozens of invocations per minute. Without the in-script throttle, each one would try to connect to Backblaze B2.\nScenario 2 with inotifywait For image optimization, inotifywait monitors the directory and reacts to new file creation:\ninotifywait -m -e close_write --include \u0026#39;\\.(png|jpg|jpeg)$\u0026#39; \\ /home/janio/Pictures/optimize/ | while read dir event file; do /home/janio/bin/optimize-images.sh done The --include with a regular expression filters events before they reach the while read. Only files with .png, .jpg, or .jpeg extensions fire the script. An .avif created by the conversion script itself generates a close_write event in the directory, but inotifywait ignores it because it does not match the filter. The loop problem that needed to be handled in the script for both launchd and systemd is solved here in the monitoring command itself — one layer earlier.\nThis is a concrete advantage of inotifywait over the declarative alternatives: the filename pattern filter happens in the monitoring tool, not in the script that processes the files. launchd\u0026rsquo;s WatchPaths does not accept filters — it fires for any change, and the script decides what to do. systemd\u0026rsquo;s PathChanged also does not filter by filename. inotifywait allows being selective before the script is even called, which reduces unnecessary executions and simplifies script logic.\nThe close_write event instead of create solves the partially written files problem discussed in the previous post. create fires the instant the file appears in the directory, before the content is fully written. A 10 MB PNG being saved by a browser fires create when the download starts and close_write when it finishes. Monitoring close_write ensures the file is complete before the script tries to convert it.\nLimitations and when it is worth it inotifywait is powerful as a monitoring mechanism but fragile as automation infrastructure. The limitations are all related to the absence of a supervisor:\nThe process needs to be running to monitor. If it dies, monitoring stops. There is no equivalent of systemd\u0026rsquo;s enable that guarantees automatic restart on boot or after a crash. It is up to the user to ensure inotifywait is running — via nohup, tmux, crontab with @reboot, or any other external persistence mechanism.\nThere is no recovery of lost events. If inotifywait was not running when the SQLite database was modified, the modification goes unnoticed. launchd with WatchPaths checks the state of monitored paths when the agent is loaded and fires if something changed during the inactive period. systemd does the same when the path unit is activated. inotifywait has no such memory — it observes from the moment it starts running, and everything that happened before does not exist.\nThere is no integrated logging. Output goes to stdout, and if nobody captured it, it is gone. Redirecting to a log file is trivial (\u0026gt;\u0026gt; /var/log/my-monitor.log 2\u0026gt;\u0026amp;1), but rotation and retention are up to the user.\nThat said, inotifywait is worth it in specific contexts: environments without systemd where installing an alternative init system would be disproportionate to the problem, throwaway scripts that need to monitor a directory for a few hours during a migration or import, and situations where event granularity justifies the extra complexity — filtering by event type and filename pattern directly in the monitoring command is something neither launchd nor systemd offer with the same precision. For permanent automation on a machine with systemd, path units are the right choice. For a one-off monitoring task or in a restricted environment, inotifywait does the job with zero dependencies beyond the kernel.\nlaunchd, systemd, and inotifywait: quick comparison Three posts, three tools, the same goal. The table below summarizes the practical differences for anyone who needs to choose — or for those who use more than one system and want to know what changes.\nlaunchd (WatchPaths) systemd (path units) inotifywait System macOS Linux with systemd Any Linux Configuration plist XML .path + .service (INI) Shell command Needs root No (LaunchAgent) No (user unit) No Kernel mechanism kqueue inotify inotify Event type filter No Partial (PathChanged vs PathModified) Yes (any inotify event) Filename filter No No (except PathExistsGlob) Yes (\u0026ndash;include / \u0026ndash;exclude) Recursive monitoring No No Yes (-r) Automatic throttle Yes (10s, adjustable upward) No (RateLimitBurst disables the path) No Lost event recovery Yes (checks state on load) Yes (checks state on activation) No Logging Manual (StandardOutPath) Automatic (journal) Manual (stdout redirection) Process supervision Automatic (launchd restarts) Automatic (systemd restarts) None (needs external supervisor) Independent testing launchctl start systemctl start .service Run the script directly The inotifywait column stands out at both extremes: the greatest event granularity and filters, but no supervision or recovery. It is the most powerful tool as an observation mechanism and the most fragile as automation infrastructure. launchd and systemd converge on most practical aspects, with specific differences — launchd\u0026rsquo;s automatic throttle, systemd\u0026rsquo;s integrated logging, systemd\u0026rsquo;s event granularity — that reflect the different philosophies of the two systems.\nFor the two scenarios in this post and the previous one — reactive backup and image optimization — any of the three tools solves the problem. The choice is dictated by the operating system and available access level, not by the tool\u0026rsquo;s technical capability. On Mac, launchd. On Linux with systemd, path units. On Linux without systemd or without control over services, inotifywait.\nWhat is left for future posts This post and the previous one covered the same territory — reactive file and directory monitoring — on the two operating systems a sysadmin probably uses day to day. The launchd plists, systemd path units, and inotifywait are three ways of saying the same thing: \u0026ldquo;when this changes, run that.\u0026rdquo; The trigger mechanics are covered.\nWhat remains are the scripts the triggers fire. The SQLite backup script needs to handle WAL checkpoint, staging directory, rclone configured with a Backblaze B2 remote, and the throttle mechanism that appeared as a concept in both posts but has not yet become code. The image conversion script is already done — with encoder fallback, protection against incomplete files, and output format and original file destination configuration.\nThese are different problems from monitoring — less about system configuration and more about application logic — and they deserve their own space to be treated with the detail they need. Future posts will deliver the complete code for each.\n","date":"17/04/2026","lang":"en","tags":["linux","inotifywait","reactive-backup","image-optimization","sqlite","file-monitoring","systemd"],"title":"Monitoring Files and Folders on Linux with systemd path units (and inotifywait for those without root)","url":"https://devops.sarmento.org/en/posts/monitoring-files-and-folders-on-linux-with-systemd-path-units-and-inotifywait/"},{"categories":["Static Sites"],"content":"In the post about Hugin I explained how I solved the tag and summary problem on my Hugo blog. But there was another problem, less obvious and more annoying: internal links. Those links that connect one post to another, help readers navigate related content, and that Google loves to see on a well-structured site.\nThe truth is nobody links anything. You write a post about systemd timers, another about cron, another about launchd — and none of the three mentions the others. They\u0026rsquo;re content islands that could be connected. The obvious solution is to reread every post, remember which others exist, and manually insert links. With 30 posts, it\u0026rsquo;s doable. With 400, it\u0026rsquo;s insane.\nSo I built Munin.\nWhat it is Munin is Hugin\u0026rsquo;s sibling — Odin\u0026rsquo;s second raven, the one of memory. While Hugin thinks (generates tags and summaries), Munin remembers (finds connections between posts). In practice, it\u0026rsquo;s another Python TUI that scans the same posts directory, but instead of generating metadata, it discovers where to insert internal links.\nHow it finds related posts Munin uses semantic embeddings. On the first run, it downloads a multilingual model (~400 MB, one time only) and generates a vector for each post based on its title, tags, and description. These vectors are cached and automatically updated when a post changes.\nWhen you select a post, Munin calculates cosine similarity against all others. It\u0026rsquo;s not keyword search — it\u0026rsquo;s semantic understanding. A post about \u0026ldquo;scheduling tasks on Linux\u0026rdquo; will find the post about \u0026ldquo;systemd timers\u0026rdquo; even if the words are completely different.\nAll of this is local, no LLM, no tokens spent. The calculation is instant.\nIncoming and outgoing links The interface offers two operations for each post:\nIncoming (i) shows which posts could link to this one. It\u0026rsquo;s the inverse question: \u0026ldquo;who in my blog should be pointing here?\u0026rdquo; Useful for spotting missed opportunities. Results are clickable links that navigate straight to the post in the list.\nOutgoing (o) is where the LLM comes in. Munin takes the candidates that embeddings found and sends them to the model along with the full post body. The prompt asks it to find exact phrases in the text that would serve as natural anchors for each candidate.\nEach suggestion appears with context — the surrounding text around the phrase that will be linked, with the anchor highlighted in bold. You know exactly what\u0026rsquo;s going to happen before you approve.\nMarkdown safety Munin never inserts links inside code blocks, headings, inline code, images, or existing links. Before showing a suggestion, it checks whether the phrase is in a safe Markdown zone. If the paragraph already has a link, it respects the configurable per-paragraph limit.\nIf the LLM suggests a phrase that doesn\u0026rsquo;t exist verbatim in the post — and it happens — Munin tries once to correct it. If it can\u0026rsquo;t, it silently discards the suggestion. No broken links, no altered text.\nLink budget Not every post needs eight internal links. Munin calculates a budget based on post length: one link per 300 words, capped at 8 per post. Posts that are too short get a warning in the metadata panel and don\u0026rsquo;t even offer the option to search for links.\nPosts that have already been analyzed with no results are marked with a visual indicator that persists between sessions — so you don\u0026rsquo;t waste time trying again.\nNew post suggestions A feature that emerged almost by accident: pressing s, Munin asks the LLM to suggest topics for new posts based on the current content. Before showing the list, it checks the embeddings to see if any of those topics already exist in the blog. The result is real content gaps — ideas for posts that would complement what you already have.\nIn practice munin ~/blog/content/posts On the first run, the embedding model is downloaded and all posts are indexed. On subsequent runs, only new or edited posts are reprocessed. The interface shows incoming and outgoing link counts in each post\u0026rsquo;s metadata, so you can see at a glance which ones are well-connected and which are islands.\nThe typical flow: navigate to a post, press o, review suggestions with context, check the ones that make sense, apply. The file is saved with atomic writes (temp file + rename) to never corrupt data. The cache updates automatically.\nShared with Hugin Munin lives in the same repository and shares infrastructure: engines, model selection, configuration. If you\u0026rsquo;ve already set up Hugin, Munin works with no additional setup. The same e key opens the same engine picker in both.\nMunin\u0026rsquo;s own configuration lives in ~/.hugin/munin.toml — link limits, embedding model, frontmatter field used for searches. The defaults work well for most blogs.\nStack Python 3.11+, Textual for the TUI, sentence-transformers with ONNX backend for embeddings (avoids the full PyTorch weight), httpx for LLM calls, python-frontmatter to read and write YAML. The embedding cache is a JSON file per directory.\nCode Hugin and Munin are open source and live in the same repository:\nRepository: github.com/janiosarmento/hugin\n","date":"17/04/2026","lang":"en","tags":["hugo","blog","automation","internal-links","embeddings","open-source","markdown","systemd"],"title":"Munin: internal links for Hugo with AI","url":"https://devops.sarmento.org/en/posts/munin-internal-links-for-hugo-with-ai/"},{"categories":["Static Sites"],"content":"Anyone who has worked with WordPress long enough develops a strong intuition for what a theme is and what it does. That intuition serves you well within the ecosystem: it guides decisions about file structure, where to put logic, and how to extend functionality. The trouble starts when you move to Hugo and try to apply the same mental model. The vocabulary overlaps — templates, layouts, partials — but what those words mean in practice is radically different.\nA WordPress theme is, at its core, a full PHP application that queries a database, makes logic decisions, and renders HTML, all in one place. A Hugo theme is a collection of templates that receives pre-processed data and does nothing more than present it. It sounds like a subtle distinction until you sit down to build your first layout and realize that almost everything you knew needs to be relearned.\nThis post does not attempt to replace Hugo\u0026rsquo;s documentation or serve as a theme-building tutorial — if you\u0026rsquo;re looking for a practical step-by-step guide to building a blog with Hugo, Pages CMS, and Cloudflare, see Why leave WordPress — and what to build instead. The goal here is to map the conceptual differences between the two worlds — the WordPress and the Hugo mental models — so that the transition is less frustrating and more productive.\nThe WordPress Mental Model The Theme as an Application In WordPress, the theme is not just a visual layer. In practice, it is the application that runs the site. A theme decides which posts to fetch, how to filter results, which fields to customize, which scripts to load, and how to assemble each page. It has direct access to the database through the WordPress API, can register custom post types, create REST endpoints, manipulate queries, and even alter admin behavior. The boundary between \u0026ldquo;theme\u0026rdquo; and \u0026ldquo;plugin\u0026rdquo; is thin — and in practice many themes cross that line without hesitation.\nThis creates a specific mental model: when a WordPress developer thinks \u0026ldquo;theme,\u0026rdquo; they think of a complete package. Data structure, presentation logic, and markup live together, often in the same PHP file. The theme is the gravitational center of the project — nearly everything passes through it or depends on it.\nThe Loop, the Database, and functions.php The heart of a WordPress theme is the Loop. Before any template renders, WordPress has already queried the database based on the URL. The theme receives that query and iterates over the results with while ( have_posts() ). It looks simple, but the implication runs deep: the theme operates at runtime, responding to each visitor request with a MySQL query.\nThe functions.php file reinforces this model. It acts as the theme\u0026rsquo;s bootstrap — where you register menus, sidebars, image sizes, enqueue scripts and stylesheets with wp_enqueue_script, add support for core features, and inevitably write logic that probably belongs in a plugin. It executes on every page load, which means any code there has access to the full WordPress state at that moment: the logged-in user, the current query, database options, available hooks. It is power and responsibility in one file — and the reason WordPress themes can become as complex as the application they supposedly just \u0026ldquo;dress up.\u0026rdquo;\nThe Hugo Mental Model The Theme as a Template Layer In Hugo, the theme is exactly what the name suggests: a presentation layer. It does not query a database because there is no database. It does not execute logic at request time because there is no request time — everything happens at build time, before the site is published. The output is a set of static HTML files that can be served by any web server with no runtime dependencies.\nA Hugo theme is a collection of template files written in Go\u0026rsquo;s template language. These templates define how content is presented, but they do not define what content exists or how it is structured. That responsibility belongs to the Markdown files and the site configuration. The theme receives everything pre-processed — taxonomies resolved, pages sorted, parameters available — and its only job is to turn that data into HTML. If the WordPress theme is the conductor leading the orchestra, the Hugo theme is the sheet music: it defines the form, but it does not play the instruments.\nContent Lives in Markdown, Logic Lives in the Build In WordPress, content lives in the database and is only accessible through the API. In Hugo, content is in text files. Each post is a Markdown file with a front matter block — metadata in YAML, TOML, or JSON at the top — followed by the body text. There is no intermediary: what sits in the content/ directory is what Hugo processes.\nThis separation has practical consequences that catch WordPress developers off guard. In WordPress, adding a custom field to a post means using the meta fields API or a plugin like ACF, then accessing the value with get_post_meta() inside the theme. In Hugo, you simply add a key to the Markdown file\u0026rsquo;s front matter and access it with .Params.keyname in the template. No intermediate layer, no plugin, no database — just text and templates. The logic available in Hugo templates is limited to conditionals, loops, and formatting functions. It exists to decide how to present data, never to fetch or transform it in complex ways. Anything more elaborate happens in Hugo\u0026rsquo;s build pipeline, outside the theme\u0026rsquo;s reach.\nWhat Catches You Off Guard Template Hierarchy: Convention vs Lookup Order In WordPress, the template hierarchy is a cascade based on file names. When a visitor hits a category page, WordPress looks for category-slug.php, then category-id.php, then category.php, then archive.php, and finally index.php. You learn this sequence once and apply it for the rest of your career. It is predictable, well-documented, and rarely causes confusion.\nIn Hugo, the equivalent system is the lookup order — and it is considerably more complex. The template used to render a page depends on a combination of content type, layout, section, output format, and language. A page of type post in section blog with layout single in Portuguese triggers a search through dozens of possible paths before Hugo finds a matching template. The documentation includes a massive table for each page kind detailing the full order. In practice, the developer coming from WordPress creates a file with the \u0026ldquo;obvious\u0026rdquo; name and gets frustrated when Hugo silently ignores it — because the name does not match any valid position in the lookup order. The learning curve here is real, and the solution is to consult the documentation until the system becomes second nature.\nChild Themes vs Hugo Overrides In WordPress, the child theme mechanism is a central piece of the ecosystem. You create a directory with a style.css referencing the parent theme, add its own functions.php, and from there you can override any parent template by placing a file with the same name in the child theme directory. This lets you customize without touching the parent theme\u0026rsquo;s code — essential protection for surviving updates.\nIn Hugo, this concept exists in a simpler and more direct form. Any template file placed in the project\u0026rsquo;s root layouts/ directory takes precedence over the equivalent file inside the theme. There is no need to declare a \u0026ldquo;child theme\u0026rdquo; or create a special configuration file. If the theme defines layouts/_default/single.html and you create the same path at your project root, Hugo uses your version. The model is transparent and works well, but it requires discipline: there is no mechanism that warns you which templates you have overridden, and after a theme update the developer needs to manually verify whether the overrides are still compatible.\nNo Plugins — Now What? The plugin ecosystem is one of WordPress\u0026rsquo;s greatest strengths and one of its greatest sources of complexity. Need a contact form? Plugin. SEO? Plugin. Cache? Plugin. Image gallery? Plugin. Themes are frequently built assuming certain plugins exist, and removing one can break the site in unpredictable ways.\nHugo has no plugin system. This is probably the difference that disorients WordPress developers the most. Functionality that would come from a plugin in WordPress is handled differently in Hugo: custom shortcodes for reusable components inside content, partials for template fragments, Hugo modules for importing functionality from external repositories, and data processing with JSON, YAML, or CSV files in the data/ directory. None of these solutions offer the convenience of WordPress\u0026rsquo;s \u0026ldquo;install and activate\u0026rdquo; — all of them require the developer to understand what they are doing and write or adapt code. On the other hand, there is no fragility of depending on third-party code executing in production on every request.\nFor maintenance tasks like keeping tags consistent and generating meta descriptions for SEO, external tools fill the gap. Hugin, for example, uses AI to suggest tags and summaries directly in Markdown files, without relying on runtime plugins.\nAssets: From enqueue to Hugo Pipes In WordPress, the correct way to include CSS and JavaScript is through the enqueue system: wp_enqueue_style() and wp_enqueue_script(). These functions register dependencies, control loading order, allow scripts to be conditional to specific pages, and prevent duplication. It is a robust system, but one that operates at runtime — each page assembles its asset list dynamically.\nHugo Pipes solves the same problem in a radically different way. Assets are processed during the build: SCSS is compiled, JavaScript is bundled and minified, fingerprinting is applied for cache busting, and the result is static files with final paths. All of this is declared in templates with functions like resources.ToCSS, resources.Minify, and resources.Fingerprint, chained in a pipeline. There is no concern about runtime loading order because there is no runtime. The template declares what it needs, Hugo processes it during the build, and the final HTML ships with correct paths to already-optimized files. For anyone who spent years fighting jQuery conflicts and scripts loading out of order, the predictability of Hugo Pipes is a relief.\nWhat Changes in Your Mind The transition from WordPress to Hugo is not just technical, but more of a shift in perspective. In WordPress, the theme developer operates with the mindset of someone building an application: they think about state, sessions, queries, and hooks that fire at specific moments in the request lifecycle. In Hugo, that complexity simply does not exist. The entire site is generated at once, in seconds, and the result is a directory of HTML files that need nothing to function beyond a web server capable of serving static files.\nThis simplicity comes with a cost of entry. The developer loses the flexibility to make decisions at request time: there is no way to show different content to a logged-in user, no way to process a form on the server, nor to perform a dynamic search without resorting to external services. Those coming from WordPress need to accept that these limitations are not flaws; they are consequences of an architectural choice that prioritizes speed, security, and predictability. A Hugo site has no runtime attack surface because there is no runtime. There is no database to be breached, no PHP to be exploited, no plugins with vulnerabilities waiting to be discovered.\nThe most significant gain, however, is cognitive. When the theme is just a presentation layer and content is text files versioned in Git, the developer can hold the complete model of the site in their head. There are no surprises hidden in the database, no implicit logic in hooks that someone added three years ago, no mysterious state that changes between one request and the next. What you see in the files is what exists. This transparency changes how you work — debugging a problem in Hugo means reading templates and checking front matter, not digging through MySQL tables trying to understand why a post looks one way on the homepage and another on the category page.\nClosing Thoughts The question is not which tool is better. WordPress remains the right choice for many projects — especially those that need dynamic content, multi-user management, or integration with a vast plugin ecosystem. The point is that moving from WordPress to Hugo without recalibrating your mental model is a recipe for frustration. The concepts do not translate directly, and trying to force one into the other only produces Hugo themes that feel like workarounds from someone still thinking in PHP and MySQL. The real investment in the transition is not learning Go template syntax — that takes days. It is unlearning twenty years of conditioned reflexes about what a theme should be and what it should do.\n","date":"04/04/2026","lang":"en","tags":["hugo","wordpress","theme-building","mental-model","static-site"],"title":"From WordPress to Hugo: Theming Is Not What You Think","url":"https://devops.sarmento.org/en/posts/from-wordpress-to-hugo-theming-is-not-what-you-think/"},{"categories":["macOS"],"content":"My inbox is always full of notifications with subjects like [Ticket ID: 12345] Ticket Update. They\u0026rsquo;re useful for a few hours and then become noise. These aren\u0026rsquo;t emails that need to be archived, replied to, or revisited, so they just end up taking up mental space.\nDeleting them manually is the kind of small task that never becomes a priority, but silently costs you in distraction. So I decided to treat it like any other recurring problem: automate it locally, without relying on external services, no webhooks, and no integrations. The idea is to periodically run a script that moves to the trash any messages whose subject matches a specific pattern and that are older than 48 hours.\nWhy not use a Mail rule The first instinct is to use Mail\u0026rsquo;s built-in rules, under Settings \u0026gt; Rules. They work well for acting the moment a message arrives, but that\u0026rsquo;s exactly the problem: they only run on the receive event.\nMail doesn\u0026rsquo;t offer a criterion like \u0026ldquo;apply this rule only if the message is older than 48 hours,\u0026rdquo; because there\u0026rsquo;s no age-based re-evaluation. With AppleScript, on the other hand, you can access Mail.app\u0026rsquo;s internal objects, including date received. This allows you to compare dates and make time-based decisions.\nThe script The core of the automation is this:\nproperty subjectPattern : \u0026#34;[Ticket ID: \u0026#34; property maxAgeHours : 48 on run set cutoffDate to (current date) - (maxAgeHours * 60 * 60) set totalDeleted to 0 tell application \u0026#34;Mail\u0026#34; repeat with acct in accounts try set inboxMailbox to mailbox \u0026#34;INBOX\u0026#34; of acct set matchingMessages to (messages of inboxMailbox whose subject contains subjectPattern and date received \u0026lt; cutoffDate) set msgCount to count of matchingMessages if msgCount \u0026gt; 0 then repeat with i from msgCount to 1 by -1 try delete item i of matchingMessages set totalDeleted to totalDeleted + 1 on error errMsg log \u0026#34;Error deleting message: \u0026#34; \u0026amp; errMsg end try end repeat end if on error errMsg log \u0026#34;Error accessing account: \u0026#34; \u0026amp; errMsg end try end repeat if totalDeleted \u0026gt; 0 then try check for new mail on error errMsg log \u0026#34;Warning: could not sync: \u0026#34; \u0026amp; errMsg end try end if log \u0026#34;Automation complete: \u0026#34; \u0026amp; totalDeleted \u0026amp; \u0026#34; message(s) moved to trash.\u0026#34; end tell end run It iterates through all accounts configured in Mail, accesses each one\u0026rsquo;s INBOX, and selects only the messages whose subject contains the defined pattern and whose received date is earlier than the calculated cutoff.\nNothing is permanently deleted. The delete command in Apple Mail only moves messages to the respective account\u0026rsquo;s Trash, which gives you room to recover something if needed.\nThe detail that prevents a flood of errors The first version I wrote iterated message by message, something like this:\n-- ❌ Problematic approach repeat with msg in inboxMessages set msgSubject to subject of msg ... end repeat In practice, this turns into a series of errors like:\nMail got an error: Can't get message id 154604 of mailbox \u0026quot;INBOX\u0026quot; of account id \u0026quot;...\u0026quot;\nThe reason usually shows up with IMAP accounts: AppleScript maintains internal references by index or ID, and these references can become invalid when there\u0026rsquo;s an ongoing sync, index changes, or messages moved by the server during iteration.\nThe solution was to delegate the filtering to Mail itself using whose:\n-- ✅ Correct approach set matchingMessages to (messages of inboxMailbox whose subject contains subjectPattern and date received \u0026lt; cutoffDate) In this format, the query is resolved internally by Mail, which returns only references it can materialize. In practice, this eliminates almost all intermittent errors.\nDeleting in reverse order When removing items from a collection, order matters. If you iterate from first to last, the indices shift with each deletion and you may end up skipping messages.\nThat\u0026rsquo;s why the loop is reversed:\nrepeat with i from msgCount to 1 by -1 delete item i of matchingMessages end repeat This avoids inconsistencies and keeps the behavior predictable.\ncontains instead of regex AppleScript has no native support for regular expressions. The contains operator works well enough when the pattern is a fixed substring like [Ticket ID:, and it avoids calling external processes.\nYou could use do shell script with grep, but that usually means spawning an external process per message, which is slower and unnecessary for this case.\nVisual interface update After messages are moved to Trash, the Mail interface doesn\u0026rsquo;t always update immediately. The check for new mail command forces a sync that usually refreshes the list in practice.\nAnother alternative would be to toggle the selected mailbox via AppleScript, but that tends to break the context of composite views — which I use — like \u0026ldquo;All Inboxes,\u0026rdquo; and doesn\u0026rsquo;t always return to the previous state correctly.\nScheduling with launchd To run periodically, scheduling is handled by launchd, not cron — which macOS does have, but whose use is discouraged. The .plist goes in ~/Library/LaunchAgents/:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.user.delete-ticket-emails\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/usr/bin/osascript\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/YOUR_USERNAME/Scripts/delete_old_ticket_emails.scpt\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StartInterval\u0026lt;/key\u0026gt; \u0026lt;integer\u0026gt;3600\u0026lt;/integer\u0026gt; \u0026lt;key\u0026gt;RunAtLoad\u0026lt;/key\u0026gt; \u0026lt;true/\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/tmp/delete-ticket-emails.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/tmp/delete-ticket-emails-error.log\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; StartInterval set to 3600 means it runs every hour. If the Mac is on, the script runs; if it isn\u0026rsquo;t, it runs the next time the session loads.\nNote that the script path includes YOUR_USERNAME, which you should replace with your actual macOS username. If you need to find yours, run whoami in the Terminal.\nTo activate:\ncp com.user.delete-ticket-emails.plist ~/Library/LaunchAgents/ launchctl load ~/Library/LaunchAgents/com.user.delete-ticket-emails.plist On the first run, macOS will ask for permission for osascript to control Mail. This authorization is under System Settings \u0026gt; Privacy \u0026amp; Security \u0026gt; Automation. Without it, the script fails and the error isn\u0026rsquo;t always very informative.\nCustomizing To adapt this to other scenarios, just change two lines at the beginning of the script:\nproperty subjectPattern : \u0026#34;[Ticket ID: \u0026#34; property maxAgeHours : 48 You can use any substring in the subject and any time window in hours. The rest of the flow stays the same.\nIn the end, this isn\u0026rsquo;t just about an automation to delete emails. It\u0026rsquo;s about reducing cognitive friction with a simple, local, and controlled solution. Mail is still Mail, but you get to decide that some messages have an expiration date and that, after that, they leave the stage on their own.\n","date":"30/03/2026","lang":"en","tags":["launchd-scheduling","osascript","mail-app","apple-script","email","automation"],"title":"Automatically deleting emails in Apple Mail with AppleScript + launchd","url":"https://devops.sarmento.org/en/posts/automatically-deleting-emails-in-apple-mail-with-applescript-and-launchd/"},{"categories":["Self-Hosting"],"content":"Anyone who runs a homelab — or even a single Raspberry Pi hosting services — eventually hits the same obstacle: how do you access those devices from outside the local network? The classic answer involves opening ports on the router, setting up port forwarding, dealing with dynamic IPs via DDNS, and hoping no bot discovers that SSH port exposed to the internet. It works, but the attack surface grows with every open port, and the maintenance becomes a silent headache that only shows up when something breaks.\nTraditional VPNs like OpenVPN and raw WireGuard solve part of the problem but bring their own complications. You need to maintain a publicly accessible VPN server — which brings us right back to exposing at least one port — plus manage keys, certificates, and routing configurations on each device. For someone who manages infrastructure professionally during the day, the last thing you want is to replicate that complexity in the homelab at night.\nThe ideal scenario would be something that connects all devices as if they were on the same local network, regardless of their physical location, without requiring any open ports on the router, without depending on a static IP, and without needing a server exposed to the internet. That is exactly what Tailscale does — and the free plan covers practically everything a homelab needs.\nWhat is Tailscale and how does it work Tailscale is a mesh VPN built on top of WireGuard — the protocol that has become the benchmark for modern VPNs by being fast, lightweight, and having a codebase small enough to be auditable. The difference is that Tailscale eliminates all the manual configuration that WireGuard normally requires: key exchange, endpoint definition, routing rules, and NAT traversal are all handled by the platform.\nThe architecture has three main components. The first is the coordination server, maintained by Tailscale, which acts as a rendezvous point where devices exchange public keys and discover how to reach each other. The second is the clients installed on each device, which establish direct WireGuard tunnels between themselves. The third is the NAT traversal mechanism, which allows two devices behind different routers — each with its own NAT — to establish a peer-to-peer connection without needing open ports. When a direct connection is not possible, traffic passes through DERP relays maintained by Tailscale, but that is the exception rather than the rule.\nOne detail worth emphasizing: the coordination server never sees the traffic between devices. It only facilitates the exchange of metadata so the nodes can find each other — all actual traffic is end-to-end encrypted by WireGuard, directly between devices. The official documentation covers the architecture in detail for those who want to dig deeper.\nThe practical result is that each device on the network gets a fixed IP in the 100.x.x.x range (the CGNAT range reserved for this type of use), accessible from any other device on the same tailnet — the name Tailscale gives to your private network. It doesn\u0026rsquo;t matter if the laptop is at a café, the server is in a datacenter, and the Raspberry Pi is behind a home router: for all practical purposes, they are on the same LAN.\ngraph TD CS[\"🔑 Coordination server\\nPublic key and endpoint exchange\"] subgraph HOME[\"🏠 Home network — NAT\"] MAC[\"💻 Mac\"] RPI[\"🍓 Raspberry Pi\"] LXC[\"📦 LXC Container\"] end subgraph DC[\"🏢 Datacenter — NAT\"] VPS[\"🖥️ VPS\"] SRV[\"🖥️ Server\"] end DERP[\"☁️ DERP Relay\\nFallback when P2P fails\"] CS -. \"metadata\" .-\u003e HOME CS -. \"metadata\" .-\u003e DC CS -. \"metadata\" .-\u003e DERP MAC \u003c== \"WireGuard tunnel\\npeer-to-peer\" ==\u003e VPS LXC \u003c== \"WireGuard tunnel\\npeer-to-peer\" ==\u003e SRV RPI \u003c== \"WireGuard tunnel\\npeer-to-peer\" ==\u003e VPS MAC \u003c-. \"via relay\" .-\u003e DERP DERP \u003c-. \"via relay\" .-\u003e SRV Installation and configuration Tailscale has packages for virtually everything that runs Linux, as well as macOS, Windows, iOS, and Android. The download page lists all options. Here we will cover the three most common homelab scenarios: Linux (Debian/Ubuntu), macOS, and LXC containers on Proxmox.\nCreating an account Before installing any client, create an account at login.tailscale.com. Tailscale does not have its own signup — authentication is done via Google, Microsoft, GitHub, or Apple. Choose whichever provider you prefer and the tailnet is created automatically. No credit card or plan selection is required — the Personal (free) plan is already active with support for 3 users and 100 devices.\nLinux (Debian/Ubuntu) The installation follows the standard pattern of adding the official repository and installing via apt. Tailscale\u0026rsquo;s convenience script handles everything in a single line:\ncurl -fsSL https://tailscale.com/install.sh | sh After installation, enable and start the daemon:\nsudo systemctl enable --now tailscaled Then authenticate the device:\nsudo tailscale up The command prints a URL in the terminal. Open it in a browser, log in with the same account created earlier, and the device appears on the tailnet. From that moment on, it already has a 100.x.x.x IP accessible by any other node on the network.\nTo confirm the status:\ntailscale status macOS There are two options: the App Store app and the CLI package via Homebrew. The App Store app is the most practical option — install it, log in, and the Mac appears on the tailnet. For those who prefer the command line:\nbrew install tailscale Start the daemon:\nsudo brew services start tailscale Authentication follows the same flow:\ntailscale up The command prints a URL in the terminal. Open it in a browser, log in with the same account created earlier, and the device appears on the tailnet.\nLXC Containers (Proxmox) Unprivileged LXC containers on Proxmox do not have access to the /dev/net/tun device by default, and Tailscale needs this device to create the network tunnel. There are two ways to fix this.\nVia the web interface (Proxmox 8+): go to the container\u0026rsquo;s Resources tab, click Add, select Device Passthrough, and enter dev/net/tun in the Device Path field.\nVia the command line, on the Proxmox host, edit the container\u0026rsquo;s configuration file (replacing \u0026lt;CTID\u0026gt; with the actual ID):\nnano /etc/pve/lxc/\u0026lt;CTID\u0026gt;.conf Add the following two lines at the end:\nlxc.cgroup2.devices.allow: c 10:200 rwm lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file Restart the container for the change to take effect. Inside it, the installation follows the standard Linux process — curl -fsSL https://tailscale.com/install.sh | sh, then sudo systemctl enable --now tailscaled and tailscale up.\nTailscale also offers a userspace networking mode that dispenses with /dev/net/tun access and requires no changes to the Proxmox configuration. The tradeoff is that in this mode Tailscale does not create a real network interface — it operates as a local SOCKS5/HTTP proxy, and each application that needs to reach the tailnet must be configured to route through the proxy. For a container running a single service that is already set up to use a proxy, this may be acceptable. But in homelab scenarios, where the goal is precisely for all services to see the 100.x.x.x IPs transparently — as if they were on the same LAN — the TUN device is the most practical option: two lines in the container\u0026rsquo;s configuration file and tailnet access works for any process, with no additional adjustments.\nTesting connectivity With at least two devices on the tailnet, the next step is to confirm they can see each other. The tailscale status command lists all nodes on the network with their respective IPs and status:\ntailscale status The output shows something like:\n100.116.82.20 mac-janio janio@ macOS - 100.85.47.3 fuqu janio@ linux - 100.97.12.58 rpi janio@ linux - To test connectivity between two nodes, use tailscale ping instead of the conventional ping:\ntailscale ping 100.116.82.20 tailscale ping operates over Tailscale\u0026rsquo;s own protocol and, in addition to confirming that the connection works, shows whether it is direct (peer-to-peer) or going through a DERP relay. A response like pong from mac-janio (100.116.82.20) via 100.116.82.20:41641 in 12ms indicates a direct connection — the ideal scenario. If via DERP(...) appears, traffic is passing through an intermediary server, which works but with higher latency.\nTraditional ping (ICMP) also works between devices that are not unprivileged containers. Inside an LXC container on Proxmox, ping fails with Operation not permitted because the container does not have the CAP_NET_RAW capability required to create raw sockets — this does not indicate a network problem. TCP and UDP connectivity works normally, and tailscale ping is the reliable way to validate communication in any scenario.\nPractical use cases The tailnet works as a virtual LAN that follows you wherever you go. This opens possibilities that go beyond simply \u0026ldquo;accessing a server from outside.\u0026rdquo; Some concrete scenarios for homelab users:\nSSH without a public IP The most immediate use case. With Tailscale installed on the server and the laptop, SSH access works via the 100.x.x.x IP regardless of where you are — without opening port 22 on the router, without DDNS, without exposing anything to the internet. For those who only need SSH without installing a VPN client on every machine, SSH-J.com is a lighter alternative that uses only OpenSSH. The server can be behind CGNAT, change IPs every time the router restarts, and nothing changes. The command remains ssh user@100.x.x.x, as if the server were right next to you. Tailscale also offers Tailscale SSH, which allows authentication via Tailscale identity without manually managing SSH keys — useful for those who want to simplify things even further.\nAccessing LM Studio remotely Anyone who runs local models on LM Studio knows that the inference server listens by default on localhost:1234. With Tailscale on the Mac running LM Studio and on the container or VPS that needs to consume the API, just configure LM Studio to listen on 0.0.0.0:1234 and point the requests to the Mac\u0026rsquo;s Tailscale IP — for example, http://100.116.82.20:1234/v1/chat/completions. The connection is end-to-end encrypted by WireGuard, without needing to expose the LM Studio port to the local network or the internet.\nSharing internal services A Raspberry Pi running AdGuard Home, a container with Immich for photos, a monitoring dashboard with Grafana — all these services become accessible from any device on the tailnet via the 100.x.x.x IP and the service port. No reverse proxy, no certificates, no external DNS configuration. For those who want to go a step further, Tailscale allows sharing devices with other users without them needing to be on the same account, which makes it easy to give family members or colleagues access to specific services.\nSubnet router — accessing the entire local network Instead of installing Tailscale on every device on the home network, it is possible to configure a single node as a subnet router. This node acts as a gateway between the tailnet and the local network: when you are away from home and try to access an IP on the home network — a printer, a NAS, a camera — the traffic goes through the WireGuard tunnel to the subnet router, which forwards the packet to the destination device and returns the response through the same path. The destination device does not even need to know that Tailscale exists.\nThe prerequisite is that the node chosen as subnet router must always be on, which makes a Raspberry Pi or a lightweight LXC container more suitable for this role than a desktop or laptop. The configuration requires a single command on that node:\nsudo tailscale up --advertise-routes=192.168.3.0/24 Then the route needs to be approved in Tailscale\u0026rsquo;s admin panel to take effect. From that point on, any device on the tailnet can reach IPs on the 192.168.3.0/24 network as if it were connected to the home router.\nMagicDNS Memorizing 100.x.x.x IPs is not practical when the tailnet starts to grow. MagicDNS solves this by automatically assigning readable names to each device — the machine\u0026rsquo;s hostname becomes a DNS record accessible by any other node on the tailnet. Instead of ssh janio@100.85.47.3, the command becomes ssh janio@fuqu, using the container\u0026rsquo;s hostname directly.\nMagicDNS is enabled by default on new tailnets. To verify or enable it manually, go to the DNS page in the admin panel and confirm the option is turned on. Each device gets a name in the format hostname.tailnet-name.ts.net — the full suffix works from anywhere, and the short hostname works when the tailnet\u0026rsquo;s search domain is configured on the operating system, which Tailscale does automatically in most cases.\nIn practice, this means internal service URLs become stable and memorable. LM Studio on the Mac becomes accessible at http://mac-janio:1234, AdGuard Home on the Raspberry Pi at http://rpi:3000, and so on. If a device changes its IP on the tailnet (which rarely happens but can occur), the name keeps pointing to the right place without manual adjustment.\nFor scenarios where the default names are not enough, the DNS panel also allows configuring split DNS — forwarding queries for specific domains to internal resolvers, useful when the tailnet coexists with existing DNS infrastructure.\nWhen Tailscale is not the best option Tailscale solves the problem of connecting distributed devices without complex configuration very well, but there are scenarios where it does not fit — or where another tool makes more sense.\nHigh-throughput traffic between servers. Tailscale adds WireGuard encapsulation overhead and, depending on the path, traffic may pass through a relay. For database replication between datacenters or massive file transfers between servers that are already on the same provider\u0026rsquo;s private network, a direct connection (VPC peering, provider private network) will always be faster and more efficient.\nExposing services to the public. Tailscale creates private networks — it connects authenticated devices, it is not a substitute for a reverse proxy or CDN. If the goal is to publish a website or API to the internet, tools like Cloudflare Tunnel, Caddy, or Nginx with a Let\u0026rsquo;s Encrypt certificate remain the way to go.\nEnvironments requiring full control-plane auditing. Tailscale\u0026rsquo;s coordination server is proprietary and runs on their infrastructure. Traffic between devices is end-to-end encrypted and never passes through Tailscale\u0026rsquo;s servers (except when using DERP relays), but the coordination metadata — which devices exist, which public keys, which IPs were assigned — is under their custody. For organizations with regulatory requirements demanding full sovereignty over this type of data, Headscale (covered in the next section) is the alternative.\nProtocols that depend on multicast. The tailnet does not support UDP multicast — protocols like mDNS, SSDP, and DLNA do not work between devices connected only through Tailscale. Automatic discovery of printers, Chromecast, and Bonjour services will not happen over the tailnet. Direct access to these devices still works via IP, but automatic discovery depends on the local network.\nNetworks with more than 3 users on the free plan. The Personal plan supports up to 3 users and 100 devices. For larger teams without a budget for paid plans, Headscale with its own authentication removes this limitation.\nHeadscale — the self-hosted alternative Headscale is an open-source reimplementation of Tailscale\u0026rsquo;s coordination server. It replaces only the control plane — the part that manages public keys, assigns IPs, and defines the network boundaries. The clients remain the official Tailscale ones, which means the experience on the end device is identical: same commands, same interface, same WireGuard protocol underneath.\nThe difference is where the control plane runs. Instead of relying on Tailscale\u0026rsquo;s infrastructure, Headscale runs on any Linux server — a cheap VPS, a container in the homelab, a dedicated machine. All network coordination is under your control, with no user limitations and no dependency on an external service.\nInstallation is simple — it is a single Go binary with no heavy dependencies. The official documentation covers initial configuration, user creation, and device registration. The authentication flow changes: instead of logging in with Google or GitHub on the Tailscale website, devices authenticate directly against your Headscale server, using pre-generated keys or OIDC if you have an identity provider configured.\nFor those already using Tailscale who want to migrate, the transition is relatively smooth — just point the clients to the new coordination server with the --login-server flag. Tailscale itself maintains compatibility with Headscale and works with the project\u0026rsquo;s maintainers when making changes to the clients that could affect the alternative server\u0026rsquo;s operation.\nThe tradeoff is maintenance. Tailscale as a service is \u0026ldquo;install and forget\u0026rdquo; — control plane updates, availability, backups, and monitoring are their responsibility. With Headscale, all of that falls on you. For a personal homelab where Tailscale\u0026rsquo;s free plan already covers everything, Headscale adds complexity without practical gain. It makes more sense when the motivation is sovereignty over coordination data, when the 3-user limit of the free plan becomes a problem, or when the philosophy of not depending on SaaS is a priority.\nFree plan vs. paid — what changes Tailscale\u0026rsquo;s Personal plan is free indefinitely, with no credit card and no trial gimmicks. What it includes covers the vast majority of homelab scenarios: up to 3 users, 100 devices, unlimited subnet routers, MagicDNS, basic ACLs (based on admin and member autogroups), and end-to-end encryption — the same foundations as the paid plans.\nPersonal Plus costs US$ 5/month and increases the limit to 6 users and 100 devices. The practical difference from the free plan is only the number of users — useful for those who want to give access to family members without sharing the same account.\nThe commercial plans (Starter at US$ 6/user/month, Premium at US$ 18/user/month, and Enterprise with pricing on request) enter different territory: granular ACLs with named groups and users, integration with identity providers like Okta and Azure AD, audit logging, SSH session recording, and priority support. These are features aimed at teams and organizations — if the tailnet is personal or for a small homelab, they are unlikely to justify the cost.\nIn practice, the relevant question for someone setting up a homelab is simple: how many people need access? If the answer is \u0026ldquo;just me\u0026rdquo; or \u0026ldquo;me and one or two other people,\u0026rdquo; the free plan has no functional limitation that gets in the way. The 100 devices are more than enough even for ambitious homelabs, and the network features — direct tunnels, subnet routing, MagicDNS, exit nodes — are all available. Tailscale as a product was designed so that the free plan is genuinely usable, not a crippled free sample that pushes you toward an upgrade.\n","date":"29/03/2026","lang":"en","tags":["tailscale","homelab","remote-access","port-forwarding","vpn-alternative","security-maintenance","homelab-management","lxc-containers","linux","macos"],"title":"Tailscale in the Homelab — Remote Access Without Opening Ports","url":"https://devops.sarmento.org/en/posts/tailscale-in-the-homelab-remote-access-without-opening-ports/"},{"categories":["Linux","Self-Hosting"],"content":"In the previous post, I showed how SSH-J.com solves a specific problem: accessing a machine behind NAT via SSH, without opening ports on the router and without relying on a public IP. The reverse tunnel works well for interactive sessions and file transfers, and SSH-J.com as a jump host makes everything trivial to configure. For SSH, it remains the simplest solution I know.\nBut SSH is just one piece of the puzzle. Anyone who maintains a homelab — even if it\u0026rsquo;s just a mini PC under the desk or a Raspberry Pi in the corner of the room — inevitably ends up running web services: an RSS reader, a monitoring dashboard, a Gitea, a Jellyfin, an Immich. These services listen on local HTTP ports and work perfectly as long as you\u0026rsquo;re on the same network. The problem appears when you want to access them from outside — from the office, from your phone on the bus, from anywhere that isn\u0026rsquo;t your local network.\nThe traditional options are the same as always: port forwarding on the router (which runs into CGNAT and exposes ports to the internet), DDNS to deal with dynamic IP (which solves only half the problem), or a VPN like WireGuard or Tailscale (which works but requires a client installed on each device). Cloudflare Tunnel offers an alternative that doesn\u0026rsquo;t require any of these things: the local service makes an outbound connection to Cloudflare\u0026rsquo;s network, which in turn publishes the service on a subdomain of your domain, with HTTPS configured automatically. No open ports, no public IP, no certificates to manage. From the outside, access is a normal URL in the browser.\nIn this post, I show how I set up my own setup: an RSS reader running in an LXC container on Proxmox, published at rss.sarmento.org through a Cloudflare Tunnel managed locally via CLI and maintained by systemd.\nHow Cloudflare Tunnel works The principle is the same as the SSH-J.com reverse tunnel, just applied to HTTP traffic instead of SSH sessions. A daemon called cloudflared runs on your local machine and opens outbound connections to Cloudflare\u0026rsquo;s edge network. Since the connection originates from inside your network, NAT is not a problem — the router treats it like any other outbound connection. Cloudflare receives the HTTP requests destined for your subdomain and forwards them through the tunnel to cloudflared, which in turn passes them to the local service.\nThe difference compared to SSH-J.com is that here there is no third-party relay operating with minimal infrastructure — it\u0026rsquo;s Cloudflare\u0026rsquo;s network, with over 300 points of presence worldwide, acting as a reverse proxy for your service. HTTPS is provided by Cloudflare using certificates generated automatically for your domain. Traffic between the visitor\u0026rsquo;s browser and Cloudflare is encrypted normally with TLS. Traffic between Cloudflare and cloudflared on your machine travels through the tunnel connection, which in turn uses QUIC or HTTP/2 with TLS. And between cloudflared and the local service, since both are on the same machine, communication happens over localhost without encryption — which is perfectly acceptable when everything runs on the same host.\nThere are two ways to manage a Cloudflare Tunnel: through the web dashboard (Zero Trust → Tunnels) or via the CLI. The dashboard is more visual and allows you to configure everything through the browser, but the result is a \u0026ldquo;remotely managed\u0026rdquo; tunnel — the configuration lives on Cloudflare and the local cloudflared only needs a token to connect. The CLI creates a \u0026ldquo;locally managed\u0026rdquo; tunnel where the configuration lives in a YAML file on the machine, which gives more control and visibility over what\u0026rsquo;s running. In this post I use the CLI because it\u0026rsquo;s the path that makes the most sense for anyone already comfortable with the terminal who wants to understand what each piece does.\nPrerequisites Before starting, you need three things.\nA domain of your own with DNS managed by Cloudflare. If your domain is already on Cloudflare (for example, because you use Cloudflare Pages to host a static site), this requirement is already met. If it isn\u0026rsquo;t, the process is to add the domain on Cloudflare and point your registrar\u0026rsquo;s nameservers to the ones Cloudflare provides. The official documentation covers this in detail.\nA free Cloudflare account. Cloudflare Tunnel is part of the free plan — no paid plan is required for personal use.\nA Linux machine running the service you want to expose. In my case it\u0026rsquo;s an LXC container with Debian 13 on Proxmox, but it can be any distribution with systemd. The service needs to be listening on a local port — in my example, a web app at http://127.0.0.1:8080.\nInstalling cloudflared cloudflared is the daemon that establishes and maintains the tunnel. On Debian, installation can be done through Cloudflare\u0026rsquo;s official repository or by downloading the .deb directly. I chose to download the package:\nwget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb sudo dpkg -i cloudflared-linux-amd64.deb Confirm that the installation worked:\ncloudflared --version The output should show the installed version. If you\u0026rsquo;re on ARM (Raspberry Pi, for example), replace amd64 with arm64 in the download URL.\nAuthenticating with Cloudflare The next step is to link cloudflared to your Cloudflare account. Run:\ncloudflared tunnel login The command will generate a URL and ask you to open it in a browser. On the page that opens, log in to your Cloudflare account and select the domain that will be used for the tunnel. After authorization, cloudflared saves a certificate at ~/.cloudflared/cert.pem that will be used to create and manage tunnels.\nIf the machine where you\u0026rsquo;re installing cloudflared doesn\u0026rsquo;t have a browser (which is the common case for servers), copy the URL displayed in the terminal and open it on any other computer where you\u0026rsquo;re logged into Cloudflare. The authorization flow happens in the browser, not on the local machine.\nCreating the tunnel With authentication done, create the tunnel:\ncloudflared tunnel create homelab The name homelab is an identifier you choose — it can be anything descriptive. The command creates the tunnel in your Cloudflare account and generates a credentials file at ~/.cloudflared/\u0026lt;UUID\u0026gt;.json, where \u0026lt;UUID\u0026gt; is the tunnel\u0026rsquo;s unique identifier. Note this UUID — you\u0026rsquo;ll need it for the configuration.\nTo confirm the tunnel was created:\ncloudflared tunnel list The output shows the name, UUID, and status of each tunnel associated with your account.\nConfiguring the routing The tunnel exists, but it doesn\u0026rsquo;t yet know where to route traffic. This configuration is done in a YAML file. Create ~/.cloudflared/config.yml:\ntunnel: \u0026lt;UUID\u0026gt; credentials-file: /root/.cloudflared/\u0026lt;UUID\u0026gt;.json ingress: - hostname: rss.sarmento.org service: http://127.0.0.1:8080 - service: http_status:404 Replace \u0026lt;UUID\u0026gt; with the actual UUID of your tunnel and adjust the hostname and port for your case. The ingress block defines the routing rules: requests for rss.sarmento.org are forwarded to the local service on port 8080, and any other request receives a 404. The last rule with service: http_status:404 is mandatory — cloudflared requires a catch-all rule at the end of the ingress.\nIf you want to expose more than one service through the same tunnel, just add entries to the ingress before the catch-all rule:\ningress: - hostname: rss.sarmento.org service: http://127.0.0.1:8080 - hostname: git.sarmento.org service: http://127.0.0.1:3000 - service: http_status:404 Each hostname needs a DNS record on Cloudflare, which is the next step.\nCreating the DNS record cloudflared has a command that automatically creates the CNAME record pointing the subdomain to the tunnel:\ncloudflared tunnel route dns homelab rss.sarmento.org This command creates a CNAME record in Cloudflare\u0026rsquo;s DNS pointing rss.sarmento.org to \u0026lt;UUID\u0026gt;.cfargotunnel.com. From this moment on, any request to rss.sarmento.org will hit Cloudflare\u0026rsquo;s network, which will look for an active tunnel with that UUID to forward it to.\nYou can verify the record in the Cloudflare dashboard, under DNS → Records for your domain. The CNAME record should appear there with the proxied status active (orange cloud).\nTesting manually Before creating the systemd service, test the tunnel manually to confirm everything works:\ncloudflared tunnel run homelab cloudflared should connect to Cloudflare\u0026rsquo;s network and start showing logs in the terminal. Open https://rss.sarmento.org in the browser — the local service should appear, served with HTTPS, without any additional certificate configuration. The terminal will show the requests being forwarded. When you\u0026rsquo;re satisfied that everything works, stop it with Ctrl+C.\nRunning as a service with systemd cloudflared has a built-in command that creates and configures the systemd service automatically:\nsudo cloudflared service install This command does three things: copies the configuration from ~/.cloudflared/config.yml to /etc/cloudflared/config.yml, copies the credentials file to /etc/cloudflared/, and creates the unit file at /etc/systemd/system/cloudflared.service with the appropriate content. The generated unit file looks like this:\n[Unit] Description=cloudflared After=network.target [Service] TimeoutStartSec=0 Type=notify ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run Restart=on-failure RestartSec=5s [Install] WantedBy=multi-user.target Type=notify indicates that cloudflared notifies systemd when it\u0026rsquo;s ready to receive traffic — a more sophisticated integration than the Restart=always we used in the SSH-J.com post. The --no-autoupdate flag disables cloudflared\u0026rsquo;s automatic update mechanism, which is the correct behavior when you manage packages through the operating system.\nEnable and start the service:\nsudo systemctl enable --now cloudflared Confirm it\u0026rsquo;s running:\nsudo systemctl status cloudflared The output should show active (running) and the connection logs. From now on the tunnel starts automatically on boot and reconnects on failure.\nDifferences compared to SSH-J.com It\u0026rsquo;s worth putting the two solutions side by side to understand when to use each one.\nSSH-J.com is ideal for SSH access to machines behind NAT. It doesn\u0026rsquo;t require an account, doesn\u0026rsquo;t require a domain, and the entire configuration is a single SSH command. The trade-off is that traffic passes through a third-party server (although encrypted end-to-end) and the service is limited to forwarding TCP connections — it can\u0026rsquo;t expose web applications with HTTPS.\nCloudflare Tunnel is for exposing HTTP and HTTPS services on the internet with your own hostname. HTTPS comes for free, DNS is managed by Cloudflare, and traffic passes through their edge network. On the other hand, it requires a domain with DNS on Cloudflare, installation of cloudflared on the machine, and a somewhat more involved configuration. It\u0026rsquo;s possible to use Cloudflare Tunnel for SSH too (Cloudflare has specific documentation for that), but the complexity is considerably greater than simply using SSH-J.com.\nIn practice, the two solutions coexist without conflict. I use SSH-J.com to access machines via SSH when I\u0026rsquo;m away from home, and Cloudflare Tunnel to publish homelab web apps with a domain and HTTPS. Each tool solves the problem it was designed for.\nWhen something goes wrong The tunnel connects but the site doesn\u0026rsquo;t load The most common problem is a mismatch between the hostname configured in the ingress and the DNS record created on Cloudflare. If the CNAME points to a different UUID than what\u0026rsquo;s in config.yml, traffic doesn\u0026rsquo;t reach the right tunnel. Check with:\ncloudflared tunnel info homelab Compare the UUID shown with what\u0026rsquo;s in config.yml and in the CNAME record on the dashboard.\nAnother frequent scenario is the local service not running or listening on a different address or port than what\u0026rsquo;s configured. If the ingress points to http://127.0.0.1:8080 but the service is on port 3000, cloudflared will get a connection refused and return a 502 error to the browser. Test the service locally before blaming the tunnel:\ncurl http://127.0.0.1:8080 If curl works but the browser doesn\u0026rsquo;t, the problem is between cloudflared and Cloudflare, not between cloudflared and the local service.\nThe systemd service keeps restarting If systemctl status cloudflared shows cycles of start → failed → auto-restart, the problem is almost certainly in the configuration. Check the logs:\njournalctl -u cloudflared -n 50 --no-pager The most common errors are: credentials file not found (cloudflared service install didn\u0026rsquo;t copy correctly, or the path in config.yml is wrong), invalid YAML in config.yml (wrong indentation, missing field), or a missing catch-all rule at the end of the ingress.\nInvalid SSL certificate in the browser If the browser shows a certificate error when accessing the subdomain, check in the Cloudflare dashboard whether the SSL/TLS mode is set to \u0026ldquo;Full\u0026rdquo; or \u0026ldquo;Flexible\u0026rdquo; — not \u0026ldquo;Off\u0026rdquo;. Cloudflare generates the certificate automatically for your domain, but the SSL mode needs to be active for it to be served. This is normally already the default for domains with active proxy.\n","date":"27/03/2026","lang":"en","tags":["cloudflare","homelab","reverse-tunnel","ssh","systemd","local-service","http-traffic","quic-tunnel"],"title":"Exposing homelab services to the internet with Cloudflare Tunnel","url":"https://devops.sarmento.org/en/posts/exposing-homelab-services-to-the-internet-with-cloudflare-tunnel/"},{"categories":["Linux","macOS"],"content":"The two previous posts built the monitoring infrastructure — WatchPaths on macOS, systemd path units and inotifywait on Linux — and promised the scripts would come later. The trigger is ready: launchd or systemd detects when something changes in a directory and fires a command. What is missing is the command itself.\nThis post delivers the image conversion script that those triggers will fire. The goal is simple: PNGs and JPGs go into a folder, WEBP or AVIF come out. The originals are deleted or moved, depending on the configuration. The script detects which encoders are available on the machine and picks the best one among those installed, with a fallback chain that ensures it works even when the ideal tool is not present. If no compatible encoder is found, the script tells you what to install and from which package manager.\nThe result is a script that can be used manually (./optimize-images.sh) or plugged directly into the plists and path units from the previous posts without any adaptation. The idea is that it works both on the Mac where someone writes blog posts and on the Linux server that processes uploads — the same logic, the same fallbacks, the same safeguards.\nThe problem with converting \u0026ldquo;later\u0026rdquo; Converting images to modern formats is one of those tasks everyone knows they should do and almost nobody does consistently. The reasons are well known: a 3 MB PNG becomes a 300 KB AVIF with visually indistinguishable quality; a 6 MB camera JPG drops to under 800 KB in WEBP. The difference is not marginal — it is an order of magnitude. For blogs, portfolios, documentation, e-commerce, any context where images are served over HTTP, the impact on load time and bandwidth consumption is direct and measurable.\nThe problem was never technical. Conversion tools have existed for years, both graphical and command-line. Google\u0026rsquo;s Squoosh converts in the browser. ImageMagick converts anything to anything. cwebp and avifenc are fast, free, and installable in one line. The barrier is behavioral: every image that passes through the workflow without being converted is a decision someone did not make. Save the PNG, open the converter, choose the format, adjust the quality, save the result, delete the original — that is six steps competing with everything else the person is doing at that moment. By the second week, the converter is no longer opened. By the third, the blog is serving 4 MB PNGs and nobody notices.\nThe solution is to eliminate the decision. The script in this post turns conversion into a process that happens without intervention: the image is saved to a directory, and the next time the user looks, it is already in WEBP or AVIF. Combined with launchd or systemd monitoring, the interval between saving and converting drops to seconds. The human handles the content; the machine handles the format.\nThe script Configuration The configuration block sits at the top of the script and concentrates all the decisions the user needs to make. There are five variables:\nWATCH_DIR=\u0026#34;$HOME/Pictures/optimize\u0026#34; OUTPUT_FORMAT=\u0026#34;avif\u0026#34; QUALITY=80 ORIGINAL_ACTION=\u0026#34;delete\u0026#34; ORIGINALS_DIR=\u0026#34;$WATCH_DIR/originals\u0026#34; OUTPUT_FORMAT accepts avif or webp. The script validates the value before doing anything and exits with an error if it is something else.\nQUALITY is the quality parameter passed to the encoder, on a scale from 0 to 100. The value 80 is a good starting point for photographic images — significant compression without visible artifacts. For screenshots with text and sharp edges, values between 85 and 95 better preserve sharpness. The parameter is passed directly to the chosen encoder, and although the scale is nominally the same across tools, the visual result for the same number may vary slightly between cwebp, avifenc, and ImageMagick. In practice, the difference is small enough not to justify conversion tables between encoders.\nORIGINAL_ACTION controls what happens to the source file after a successful conversion. With delete, the original is removed. With move, it is moved to the subdirectory defined in ORIGINALS_DIR — useful for anyone who wants a safety net before fully trusting the quality of automatic conversion. The originals directory is created automatically if it does not exist.\nEncoder detection Before processing any image, the script needs to find out what is installed on the machine. The approach is straightforward: test the presence of each encoder with command -v, which returns success if the binary exists in PATH and fails silently if it does not.\ndetect_encoder() { local format format=\u0026#34;$1\u0026#34; case \u0026#34;$format\u0026#34; in avif) if command -v avifenc \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;avifenc\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;avif\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libaom-av1\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; webp) if command -v cwebp \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;cwebp\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;webp\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libwebp\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; esac } The function returns the name of the chosen encoder as a string, or an empty string if no encoder is found. The caller checks the result and acts accordingly — either proceeds with conversion or exits with a help message.\nFor ImageMagick, the detection has a subtlety. Newer versions (7.x) use the magick binary as a unified entry point; older versions (6.x) use convert directly. The script tests magick first and falls back to convert if needed. But the presence of the binary does not guarantee format support — an ImageMagick installation compiled without the right delegates may not know how to read or write AVIF or WEBP. That is why the test with convert -list format comes before accepting ImageMagick as a valid encoder: if the format does not appear in the list of supported formats, the encoder is discarded and detection continues to the next candidate.\nffmpeg is the last resort in both chains. It is a video tool that also converts images, but it is not the best choice for the job — the parameters are less intuitive, the documentation is geared toward video workflows, and quality control for still images is less refined. It works, but if ffmpeg is the only encoder available, it is probably worth installing the dedicated tool.\nThe fallback hierarchy The order of preference is not arbitrary. For each format, the dedicated encoder comes first because it offers the best control over compression parameters and produces the best results for the same quality level.\nFor AVIF, the chain is: avifenc → ImageMagick → ffmpeg. avifenc (from the libavif package) is the reference — developed by the Alliance for Open Media, the same organization responsible for the format. It accepts granular parameters like speed (encoding speed vs. compression efficiency) and supports 10- and 12-bit color depth. ImageMagick delegates to libavif or libaom internally, so the result is comparable, but the fine-tuning parameters are hidden behind the generic convert interface. ffmpeg uses libaom-av1 and works, but the syntax for still images is uncomfortable — the concept of \u0026ldquo;one video frame\u0026rdquo; as an image is a forced fit.\nFor WEBP, the chain is: cwebp → ImageMagick → ffmpeg. cwebp (from Google\u0026rsquo;s webp package) is the reference encoder, with precise quality control, support for lossy and lossless compression profiles, and output optimized for photographic images. ImageMagick uses libwebp internally and produces equivalent results. ffmpeg with libwebp works but, again, is the least ergonomic option.\nThe conversion function receives the encoder name and dispatches to the correct syntax:\nconvert_image() { local input output encoder quality input=\u0026#34;$1\u0026#34; output=\u0026#34;$2\u0026#34; encoder=\u0026#34;$3\u0026#34; quality=\u0026#34;$4\u0026#34; case \u0026#34;$encoder\u0026#34; in avifenc) avifenc --min 0 --max 63 -a end-usage=q \\ -a cq-level=$((63 - quality * 63 / 100)) \\ --speed 6 \u0026#34;$input\u0026#34; \u0026#34;$output\u0026#34; ;; cwebp) cwebp -q \u0026#34;$quality\u0026#34; \u0026#34;$input\u0026#34; -o \u0026#34;$output\u0026#34; ;; magick) magick \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; convert) convert \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; ffmpeg) if [[ \u0026#34;$output\u0026#34; == *.avif ]]; then ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libaom-av1 -crf $((63 - quality * 63 / 100)) \\ -still-picture 1 \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null else ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libwebp -quality \u0026#34;$quality\u0026#34; \\ \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null fi ;; esac } avifenc has a quirk: its quality parameter (cq-level) uses an inverted scale, where 0 is the best quality and 63 is the worst. The script translates the 0–100 value to 63–0 automatically, so QUALITY=80 at the top of the script means the same thing regardless of which encoder is selected. ffmpeg with libaom-av1 uses crf on the same inverted scale and receives the same conversion. For cwebp, ImageMagick, and ffmpeg with libwebp, the value is passed directly — all three tools use 0–100 where higher values mean better quality.\nProtection against incomplete files When the script is triggered by a WatchPaths or PathChanged, execution begins seconds after the file appears in the directory. But \u0026ldquo;appears\u0026rdquo; does not mean \u0026ldquo;is complete.\u0026rdquo; A browser saving a large image, an editing application exporting a high-resolution PNG, a cp from a slow network volume — in all these cases, the file is created (and the trigger fired) before the content has been fully written to disk.\nConverting a partially written image produces one of two things: a corrupted output file that looks like half the image, or an encoder error that terminates the script. Neither is acceptable, especially if ORIGINAL_ACTION is set to delete — deleting the original of a file that was not successfully converted is data loss.\nThe protection is a loop that compares the file size at two moments separated by a short interval:\nwait_for_stable() { local filepath size_before size_after filepath=\u0026#34;$1\u0026#34; for _ in 1 2 3; do size_before=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) sleep 1 size_after=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) if [[ \u0026#34;$size_before\u0026#34; == \u0026#34;$size_after\u0026#34; \u0026amp;\u0026amp; \u0026#34;$size_after\u0026#34; != \u0026#34;0\u0026#34; ]]; then return 0 fi done return 1 } stat has different syntax on macOS and Linux — -c%s on GNU coreutils, -f%z on BSD. The script tries both and uses whichever works. The loop makes up to three attempts with a one-second interval between each comparison. If after three rounds the size is still changing, the function returns failure and the script skips that file — it will be processed on the next execution, when the trigger fires again after the write completes.\nThe \u0026quot;$size_after\u0026quot; != \u0026quot;0\u0026quot; test protects against a subtle edge case: a file that was created but is still empty (zero size in both measurements). This can happen when an application creates the file and opens the file descriptor but has not yet started writing. Without this check, the script would consider the file stable (the size did not change) and attempt to convert an empty file.\nConversion and cleanup The main loop scans the directory, processes each eligible file, and handles the originals:\nprocess_images() { local encoder file basename output encoder=$(detect_encoder \u0026#34;$OUTPUT_FORMAT\u0026#34;) if [[ -z \u0026#34;$encoder\u0026#34; ]]; then suggest_install \u0026#34;$OUTPUT_FORMAT\u0026#34; exit 1 fi echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Encoder: $encoder (format: $OUTPUT_FORMAT)\u0026#34; find \u0026#34;$WATCH_DIR\u0026#34; -maxdepth 1 -type f \\ \\( -iname \u0026#39;*.png\u0026#39; -o -iname \u0026#39;*.jpg\u0026#39; -o -iname \u0026#39;*.jpeg\u0026#39; \\) | while IFS= read -r file; do basename=\u0026#34;${file%.*}\u0026#34; output=\u0026#34;${basename}.${OUTPUT_FORMAT}\u0026#34; if ! wait_for_stable \u0026#34;$file\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Skipped (still writing): $file\u0026#34; continue fi if convert_image \u0026#34;$file\u0026#34; \u0026#34;$output\u0026#34; \u0026#34;$encoder\u0026#34; \u0026#34;$QUALITY\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Converted: $file -\u0026gt; $output\u0026#34; handle_original \u0026#34;$file\u0026#34; else echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Failed: $file\u0026#34; rm -f \u0026#34;$output\u0026#34; fi done } The find with -maxdepth 1 avoids descending into subdirectories — including originals/, which would be disastrous if moved originals were reprocessed. The -iname makes the search case-insensitive, catching .PNG, .png, and .Jpg alike. The while IFS= read -r instead of for file in $(find ...) correctly handles filenames with spaces.\nWhen conversion fails, rm -f \u0026quot;$output\u0026quot; deletes any partial output file the encoder may have created before aborting. Leaving a corrupted AVIF in the directory would cause confusion — it would look like the conversion succeeded, but the image would be broken.\nThe handle_original function encapsulates the decision configured in ORIGINAL_ACTION:\nhandle_original() { local file file=\u0026#34;$1\u0026#34; case \u0026#34;$ORIGINAL_ACTION\u0026#34; in delete) rm -f \u0026#34;$file\u0026#34; ;; move) mkdir -p \u0026#34;$ORIGINALS_DIR\u0026#34; mv \u0026#34;$file\u0026#34; \u0026#34;$ORIGINALS_DIR/\u0026#34; ;; esac } The last piece is the function that suggests installation when no encoder is found:\nsuggest_install() { local format format=\u0026#34;$1\u0026#34; echo \u0026#34;Error: no encoder found for $format.\u0026#34; if [[ \u0026#34;$(uname)\u0026#34; == \u0026#34;Darwin\u0026#34; ]]; then case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: brew install libavif\u0026#34; ;; webp) echo \u0026#34;Install with: brew install webp\u0026#34; ;; esac else case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: sudo apt install libavif-bin\u0026#34; ;; webp) echo \u0026#34;Install with: sudo apt install webp\u0026#34; ;; esac fi } The OS detection uses uname — Darwin for macOS, anything else assumes Linux with apt. This is a deliberate simplification: the script does not try to detect whether the system uses dnf, pacman, or apk. Anyone running Fedora or Arch knows how to find the equivalent package; anyone running Debian, Ubuntu, or any derivative — which is the vast majority of Linux servers in production — gets the correct suggestion.\nThe complete script Each function was explained in isolation in the previous sections. Here is the assembled script, ready to save to ~/bin/optimize-images.sh and make executable with chmod +x:\n#!/usr/bin/env bash set -euo pipefail # -- Config ------------------------------------------------------------------ WATCH_DIR=\u0026#34;$HOME/Pictures/optimize\u0026#34; OUTPUT_FORMAT=\u0026#34;avif\u0026#34; QUALITY=80 ORIGINAL_ACTION=\u0026#34;delete\u0026#34; ORIGINALS_DIR=\u0026#34;$WATCH_DIR/originals\u0026#34; # -- Functions --------------------------------------------------------------- detect_encoder() { local format format=\u0026#34;$1\u0026#34; case \u0026#34;$format\u0026#34; in avif) if command -v avifenc \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;avifenc\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;avif\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libaom-av1\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; webp) if command -v cwebp \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;cwebp\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;webp\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libwebp\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; esac } suggest_install() { local format format=\u0026#34;$1\u0026#34; echo \u0026#34;Error: no encoder found for $format.\u0026#34; if [[ \u0026#34;$(uname)\u0026#34; == \u0026#34;Darwin\u0026#34; ]]; then case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: brew install libavif\u0026#34; ;; webp) echo \u0026#34;Install with: brew install webp\u0026#34; ;; esac else case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: sudo apt install libavif-bin\u0026#34; ;; webp) echo \u0026#34;Install with: sudo apt install webp\u0026#34; ;; esac fi } wait_for_stable() { local filepath size_before size_after filepath=\u0026#34;$1\u0026#34; for _ in 1 2 3; do size_before=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) sleep 1 size_after=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) if [[ \u0026#34;$size_before\u0026#34; == \u0026#34;$size_after\u0026#34; \u0026amp;\u0026amp; \u0026#34;$size_after\u0026#34; != \u0026#34;0\u0026#34; ]]; then return 0 fi done return 1 } convert_image() { local input output encoder quality input=\u0026#34;$1\u0026#34; output=\u0026#34;$2\u0026#34; encoder=\u0026#34;$3\u0026#34; quality=\u0026#34;$4\u0026#34; case \u0026#34;$encoder\u0026#34; in avifenc) avifenc --min 0 --max 63 -a end-usage=q \\ -a cq-level=$((63 - quality * 63 / 100)) \\ --speed 6 \u0026#34;$input\u0026#34; \u0026#34;$output\u0026#34; ;; cwebp) cwebp -q \u0026#34;$quality\u0026#34; \u0026#34;$input\u0026#34; -o \u0026#34;$output\u0026#34; ;; magick) magick \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; convert) convert \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; ffmpeg) if [[ \u0026#34;$output\u0026#34; == *.avif ]]; then ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libaom-av1 -crf $((63 - quality * 63 / 100)) \\ -still-picture 1 \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null else ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libwebp -quality \u0026#34;$quality\u0026#34; \\ \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null fi ;; esac } handle_original() { local file file=\u0026#34;$1\u0026#34; case \u0026#34;$ORIGINAL_ACTION\u0026#34; in delete) rm -f \u0026#34;$file\u0026#34; ;; move) mkdir -p \u0026#34;$ORIGINALS_DIR\u0026#34; mv \u0026#34;$file\u0026#34; \u0026#34;$ORIGINALS_DIR/\u0026#34; ;; esac } process_images() { local encoder file basename output encoder=$(detect_encoder \u0026#34;$OUTPUT_FORMAT\u0026#34;) if [[ -z \u0026#34;$encoder\u0026#34; ]]; then suggest_install \u0026#34;$OUTPUT_FORMAT\u0026#34; exit 1 fi echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Encoder: $encoder (format: $OUTPUT_FORMAT)\u0026#34; find \u0026#34;$WATCH_DIR\u0026#34; -maxdepth 1 -type f \\ \\( -iname \u0026#39;*.png\u0026#39; -o -iname \u0026#39;*.jpg\u0026#39; -o -iname \u0026#39;*.jpeg\u0026#39; \\) | while IFS= read -r file; do basename=\u0026#34;${file%.*}\u0026#34; output=\u0026#34;${basename}.${OUTPUT_FORMAT}\u0026#34; if ! wait_for_stable \u0026#34;$file\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Skipped (still writing): $file\u0026#34; continue fi if convert_image \u0026#34;$file\u0026#34; \u0026#34;$output\u0026#34; \u0026#34;$encoder\u0026#34; \u0026#34;$QUALITY\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Converted: $file -\u0026gt; $output\u0026#34; handle_original \u0026#34;$file\u0026#34; else echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Failed: $file\u0026#34; rm -f \u0026#34;$output\u0026#34; fi done } # -- Validation -------------------------------------------------------------- if [[ \u0026#34;$OUTPUT_FORMAT\u0026#34; != \u0026#34;avif\u0026#34; \u0026amp;\u0026amp; \u0026#34;$OUTPUT_FORMAT\u0026#34; != \u0026#34;webp\u0026#34; ]]; then echo \u0026#34;Error: OUTPUT_FORMAT must be \u0026#39;avif\u0026#39; or \u0026#39;webp\u0026#39;, got \u0026#39;$OUTPUT_FORMAT\u0026#39;\u0026#34; exit 1 fi if [[ \u0026#34;$ORIGINAL_ACTION\u0026#34; != \u0026#34;delete\u0026#34; \u0026amp;\u0026amp; \u0026#34;$ORIGINAL_ACTION\u0026#34; != \u0026#34;move\u0026#34; ]]; then echo \u0026#34;Error: ORIGINAL_ACTION must be \u0026#39;delete\u0026#39; or \u0026#39;move\u0026#39;, got \u0026#39;$ORIGINAL_ACTION\u0026#39;\u0026#34; exit 1 fi mkdir -p \u0026#34;$WATCH_DIR\u0026#34; # -- Main -------------------------------------------------------------------- process_images Save, make executable, and test manually before plugging into launchd or systemd:\nchmod +x ~/bin/optimize-images.sh cp some-photo.jpg ~/Pictures/optimize/ ~/bin/optimize-images.sh ls -lh ~/Pictures/optimize/ The ls -lh confirms that the AVIF (or WEBP) was created and shows the size comparison. If everything worked, the original file is gone (or was moved to originals/, depending on the configuration). From here, all that remains is to activate the trigger in the operating system — which is the subject of the next section.\nInstalling the encoders The script works with any encoder in the fallback chain, but the best results come from the dedicated tools — avifenc for AVIF and cwebp for WEBP. These offer the best quality control, better compression for the same visual level, and the most predictable parameters. ImageMagick and ffmpeg work as a safety net, not as a first choice.\nmacOS (Homebrew) For AVIF:\nbrew install libavif The package installs avifenc (encoder) and avifdec (decoder). The encoder is compiled with libaom support, which is the reference AV1 implementation — the same one ffmpeg would use, but with an interface optimized for still images instead of video.\nFor WEBP:\nbrew install webp The package installs cwebp (encoder), dwebp (decoder), and webpinfo (metadata inspector). All three are from Google\u0026rsquo;s official project.\nTo install everything at once and cover both formats:\nbrew install libavif webp ImageMagick (brew install imagemagick) and ffmpeg (brew install ffmpeg) are probably already installed on machines belonging to anyone who works with media or development. If they are, the script already detects them as fallbacks without any additional action. Installing them exclusively for image conversion would be disproportionate — they are large packages with dozens of dependencies.\nLinux (apt) For AVIF:\nsudo apt install libavif-bin The package installs avifenc and avifdec. On distributions based on Debian 12 (Bookworm) and Ubuntu 22.04 or newer, the package is available in the default repositories. On older versions, it may not exist or may contain a very old version of libavif — in that case, ImageMagick with AVIF support (if available) or ffmpeg with libaom-av1 take over via fallback.\nFor WEBP:\nsudo apt install webp The package installs cwebp, dwebp, and auxiliary tools. It is available on virtually any version of Debian and Ubuntu still in support — it is a stable package that has been in the repositories for years.\nBoth together:\nsudo apt install libavif-bin webp One difference from macOS: on Linux servers, it is common for ImageMagick to already be installed as a dependency of some web framework (PHP, Rails, Django) but compiled without AVIF support. The convert -list format | grep -i avif that the script uses in detection checks exactly this — the presence of the binary is not enough, the format needs to be in the list of compiled delegates. If the grep does not find AVIF, ImageMagick is discarded and detection moves on to ffmpeg. This is why installing libavif-bin directly is more reliable than depending on ImageMagick for AVIF in server environments.\nIntegrating with launchd and systemd The previous posts explained in detail how WatchPaths and systemd path units work. This section is just a quick reference — the plist and the .path + .service pair ready to copy, without repeating the theory.\nThe plist (macOS) Save to ~/Library/LaunchAgents/com.janio.image-optimizer.plist:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.image-optimizer\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/optimize-images.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/Pictures/optimize\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; Load and activate:\nmkdir -p ~/.local/log launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.janio.image-optimizer.plist To verify it is running:\nlaunchctl print gui/$(id -u)/com.janio.image-optimizer To unload if you need to edit the plist:\nlaunchctl bootout gui/$(id -u)/com.janio.image-optimizer The /opt/homebrew/bin in PATH is where avifenc and cwebp live after installation via Homebrew on Apple Silicon Macs. On Intel Macs, the path would be /usr/local/bin, which is already in the list. The log directory needs to exist before the first execution — launchd does not create it automatically.\nThe .path + .service (Linux) Save to ~/.config/systemd/user/image-optimizer.path:\n[Path] PathChanged=/home/janio/Pictures/optimize [Install] WantedBy=default.target Save to ~/.config/systemd/user/image-optimizer.service:\n[Service] Type=oneshot ExecStart=/home/janio/bin/optimize-images.sh Environment=PATH=/usr/local/bin:/usr/bin:/bin Activate:\nsystemctl --user daemon-reload systemctl --user enable --now image-optimizer.path To check the monitoring status:\nsystemctl --user status image-optimizer.path To see the output of recent executions:\njournalctl --user -u image-optimizer.service -n 30 The daemon-reload is necessary when files are created or edited — systemd does not detect new units automatically. Logging goes to the journal without any additional configuration, unlike launchd where the log file path needs to be declared in the plist.\nOn both systems, the workflow from here is the same: save a PNG or JPG image to ~/Pictures/optimize/, wait a few seconds, and verify that the AVIF or WEBP appeared in place of the original. If something does not work, the log (file on macOS, journal on Linux) shows exactly where the process stopped.\nTesting before automating Plugging the script into launchd or systemd without first confirming that it works in isolation is asking to debug two problems at once — the script and the trigger — without knowing which one is failing. The manual test is quick and eliminates an entire layer of uncertainty.\nFirst, create the directory and copy some test images:\nmkdir -p ~/Pictures/optimize cp some-photo.jpg ~/Pictures/optimize/ cp screenshot.png ~/Pictures/optimize/ Run the script directly:\n~/bin/optimize-images.sh The output should show the detected encoder and the result of each conversion:\n2026-03-26 14:32:01 Encoder: avifenc (format: avif) 2026-03-26 14:32:04 Converted: /Users/janio/Pictures/optimize/some-photo.jpg -\u0026gt; /Users/janio/Pictures/optimize/some-photo.avif 2026-03-26 14:32:06 Converted: /Users/janio/Pictures/optimize/screenshot.png -\u0026gt; /Users/janio/Pictures/optimize/screenshot.avif If Error: no encoder found appears, the message already tells you what to install. If the encoder is detected but conversion fails, the problem is with the parameters or the input image — testing with the encoder directly (avifenc input.png output.avif) isolates the script from the tool.\nComparing sizes confirms that the conversion is producing reasonable results:\nls -lh ~/Pictures/optimize/ A 3 MB JPG that became a 300 KB AVIF is within expectations. An AVIF larger than the original suggests the quality level is too high for that image, or the original was already heavily compressed. Adjusting QUALITY at the top of the script and running again takes seconds.\nAlso check what happened to the originals. If ORIGINAL_ACTION is delete, the PNGs and JPGs should be gone. If it is move, they should be in ~/Pictures/optimize/originals/. If they are still in place, something failed during conversion and the script did not touch the originals — which is the correct behavior when conversion errors occur.\nTo test the fallback, temporarily uninstall the primary encoder and run again:\nbrew uninstall libavif # macOS ~/bin/optimize-images.sh brew install libavif # reinstall afterward The script should fall back to ImageMagick or ffmpeg and keep working, with the log line showing the substitute encoder. If no encoder is installed at all, the error message with the installation suggestion appears and the script exits without touching any files.\nOnly after confirming the script works in all these scenarios — normal conversion, fallback, no encoder, empty directory, ORIGINAL_ACTION in both modes — is it worth activating the trigger in the operating system. From that point on, the only test left is to save an image to the monitored directory and check the log to confirm that launchd or systemd fired the script correctly. If the script has already been validated in isolation, any problem at this stage is with the trigger, not the script — and the diagnosis becomes trivial.\n","date":"26/03/2026","lang":"en","tags":["image-conversion","bash","encoder-fallback","static-site","image-optimization","automation","webp","avif"],"title":"Automatically Converting Images to WEBP and AVIF","url":"https://devops.sarmento.org/en/posts/automatically-converting-images-to-webp-and-avif/"},{"categories":["macOS"],"content":"In the previous post about launchd, scheduling worked by time: StartCalendarInterval defined \u0026ldquo;every day at 7 AM\u0026rdquo; and the system took care of the rest, including recovering missed executions when the Mac was asleep. For periodic tasks like sending a daily briefing or running a maintenance script, that model works perfectly — it is the functional equivalent of cron, but integrated into the macOS lifecycle.\nBut not every automation makes sense tied to a clock. Some tasks only need to happen when something changes. A backup that runs every hour is wasting 23 executions per day if the database was only modified once. An image conversion that runs every 5 minutes has nothing to convert most of the time, and when it finally does, up to 5 minutes have passed since the file appeared. The time-based model works, but it is polling disguised as scheduling — and polling is almost always the least elegant solution to any synchronization problem.\nlaunchd offers an alternative that works in a fundamentally different way: instead of asking \u0026ldquo;what time is it?\u0026rdquo;, it asks \u0026ldquo;did something change?\u0026rdquo;. The WatchPaths key accepts a list of paths — files or directories — and fires the job when any of them is modified. It is not an interval. It is not cron in disguise. It is a reactive trigger based on filesystem events that turns launchd into a kind of Linux inotifywait, but declarative and integrated into the operating system.\nThis post explores two practical scenarios for this capability. The first is an automatic SQLite backup that syncs with a remote server whenever the database is modified, with a throttle mechanism to avoid hammering the destination on every save. The second is an image converter that watches a folder and transforms PNGs and JPGs into optimized formats as soon as they appear. The scripts themselves are covered in dedicated posts — the image conversion script is already published. The focus here is on the launchd mechanics and plist construction.\nFrom Schedules to Events: the Other Side of launchd The StartCalendarInterval from the previous post and the WatchPaths we will use here are mutually exclusive in the same plist. Each LaunchAgent has a single activation trigger: it either fires by time, or by a filesystem change, or by another condition like StartInterval (a simple timer in seconds) or KeepAlive (which keeps the process running continuously and restarts it if it dies). Mixing StartCalendarInterval with WatchPaths in the same plist does not produce a syntax error, but the resulting behavior is undefined and Apple\u0026rsquo;s documentation does not guarantee which one takes precedence.\nThe restriction makes sense when you think about launchd\u0026rsquo;s mental model: each plist describes a job, and each job has a reason to exist — a condition that brings it to life. If you need a script that runs at 7 AM and when a file changes, the solution is to create two plists pointing to the same script (or to different scripts that share the same logic). It seems redundant compared to a systemd timer that accepts multiple OnCalendar and PathChanged in the same unit, but in practice the separation keeps jobs simple and makes debugging easier — each plist does one thing, and when something breaks, the problem is isolated in a single file.\nWatchPaths: How It Works The WatchPaths syntax is an array of strings in the plist, where each string is the absolute path of a file or directory. When launchd detects a modification on any of the listed paths, it executes the job. Detection uses the kernel\u0026rsquo;s kqueue — the same mechanism that fswatch and FSEvents use under the hood — so there is no polling involved. The overhead is zero while nothing changes, and the reaction is practically instantaneous when something does.\nHere is the minimal form of a plist with WatchPaths:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.example.watchpaths\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/my-script.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/data/file.db\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; Every time /Users/janio/data/file.db is modified — whether by a direct write, by a mv that replaces the file, or by any operation that changes its metadata — launchd executes /Users/janio/bin/my-script.sh. No loop needed, no while true, no resident process consuming memory. The script runs, does what it needs to do, and exits. On the next modification, launchd runs it again.\nOne point that is not obvious in the documentation: when the monitored path is a directory, launchd fires when any file inside it is created, removed, or renamed, but not necessarily when the content of an existing file is modified without changing the directory structure. The exact behavior depends on how the application writing the file implements the write — some editors create a temporary file and do an atomic rename (which modifies the directory), while others write directly to the existing file (which may not trigger the directory watch). To monitor the content of a specific file, the most reliable practice is to point WatchPaths directly at the file, not at the directory containing it.\nIt is also worth knowing that WatchPaths is not recursive. Monitoring /Users/janio/data/ detects changes at the immediate level of that directory, but not in subfolders. If the directory structure matters, each relevant path needs to be listed explicitly in the array. This is a limitation compared to Linux\u0026rsquo;s inotifywait --recursive, but in practice most use cases involve watching one or two specific paths, not an entire tree.\nScenario 1: Reactive SQLite Backup The Problem SQLite databases are files. This is simultaneously SQLite\u0026rsquo;s greatest quality and greatest risk: there is no intermediary server, no daemon managing connections, no built-in replication. The database is a .db file on disk, and if that file gets corrupted or the disk fails, the data goes with it. For personal applications that store data that matters — a task manager, an RSS reader, a home inventory — the absence of automatic backup is a silent time bomb that only goes off at the worst possible moment.\nThe traditional approach would be to schedule a periodic backup with StartCalendarInterval — say, every hour. It works, but has two problems. First, most executions will copy a database that hasn\u0026rsquo;t changed since the last backup, wasting time and bandwidth with the remote destination. Second, if you make a series of important changes and the laptop dies before the next full hour, the changes are lost. The fixed interval creates a vulnerability window proportional to its frequency, and reducing the frequency to one minute turns the backup into aggressive polling that consumes resources needlessly.\nWith WatchPaths, the model changes: the backup only runs when the database is actually modified. No change, no process runs. With a change, the script fires in seconds. The vulnerability window drops from \u0026ldquo;up to an hour\u0026rdquo; to \u0026ldquo;up to a few seconds,\u0026rdquo; and the resource cost drops to zero during periods of inactivity.\nThe Script Logic The backup script does four things in order: copy the relevant files to a local staging directory, force a WAL checkpoint on the SQLite database to ensure consistency, sync the staging with the remote destination via rclone, and record the execution time for throttle control.\nThe staging directory exists to prevent rclone from reading the database directly while it is being written. SQLite in WAL mode (Write-Ahead Logging) maintains a -wal file alongside the main database with transactions not yet consolidated. Copying the .db without first running a PRAGMA wal_checkpoint(TRUNCATE) can result in an inconsistent backup — the main file missing the latest transactions and the WAL absent or truncated. The checkpoint forces WAL consolidation into the main database before the copy, and staging ensures that rclone works with a static snapshot, not a file that might change during upload.\nThe remote destination in this case is a Backblaze B2 bucket, but the logic is identical for any remote that rclone supports — S3, Google Drive, SFTP, a local NAS. rclone sync handles transferring only the blocks that changed, so even a multi-megabyte database generates minimal traffic when the changes are small.\nThe Plist \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.fuqu-backup\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/fuqu-backup.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/share/fuqu/fuqu.db\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/share/fuqu/backup.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/share/fuqu/backup.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; The WatchPaths points directly at the fuqu.db file, not at the directory containing it. As mentioned in the previous section, monitoring the directory might not capture writes made directly to the file by SQLite — especially in WAL mode, where the initial write goes to the -wal and is only later consolidated into the main database. Pointing at the .db ensures that launchd fires when the database itself is modified, including after an automatic SQLite checkpoint.\nThe PATH in EnvironmentVariables includes /opt/homebrew/bin because that is where Homebrew installs binaries on Apple Silicon Macs, and rclone comes from there. Without this variable, the script would not find rclone — the same PATH issue that came up in the previous post with fuqu telegram.\nThrottle: Why You Cannot Trust launchd Alone There is a detail that Apple\u0026rsquo;s documentation mentions in passing but that has serious practical implications: launchd has an internal throttle of 10 seconds between executions of the same job. If the database is modified twice in quick succession, the second execution is delayed to respect this minimum interval. The value can be adjusted with the ThrottleInterval key in the plist, but only upward — it cannot be reduced below 10 seconds.\nFor a remote backup, 10 seconds is actually too little. An application that makes many small writes to SQLite — adding a task, marking another as complete, editing a note — can generate dozens of database modifications in a few minutes of active use. Firing an rclone sync for each one does not make sense: each execution has the overhead of connecting to the remote server, authenticating, comparing checksums, and transferring data. On a B2 bucket, each write operation is an API transaction that counts toward the monthly free limit.\nThe solution is to implement the throttle in the script itself, independent of launchd. The mechanism is simple: the script writes a timestamp to a control file after each successful backup. On the next execution, before doing anything, it reads that timestamp and calculates how much time has passed. If the interval is less than the defined limit — five minutes, for example — the script exits immediately with a log message and exit code 0. launchd sees the execution as successful and goes back to sleep until the next database modification.\nThe result is a two-layer system: launchd ensures the script is called when the database changes, and the script ensures the actual backup does not happen more than once every five minutes. launchd handles reactivity; the script handles containment. Separating responsibilities this way keeps the plist simple and the business logic where it belongs — in the script, where it can be tested, adjusted, and versioned independently of the operating system configuration.\nScenario 2: Automatic Image Optimization The Problem Modern image formats like WEBP and AVIF offer significantly better compression than PNG and JPG without perceptible quality loss. A 2 MB PNG becomes a 200 KB AVIF with a visually indistinguishable result; a 5 MB camera JPG drops to less than 1 MB in WEBP. The difference is large enough to matter in any context where images are served — blogs, portfolios, documentation, even email attachments.\nThe problem is that nobody wants to open a converter manually every time they save an image. Graphical tools like Squoosh exist and work well for one or two images, but conversion needs to become a habit to have real impact, and habits that depend on manual steps die in the second week. What works is removing the human from the process: save the image in a specific directory, and the conversion happens on its own.\nOn Linux, the classic solution would be an inotifywait loop inside a script monitoring the folder, or a systemd path unit activating a service. On macOS, launchd\u0026rsquo;s WatchPaths does the same job with fewer moving parts: point at the directory, and launchd notifies when something appears.\nThe Script Logic The script monitors a directory — for example, ~/Pictures/optimize/ — and processes any PNG or JPG file it finds there. For each image found, it generates an AVIF (or WEBP, depending on preference) version, verifies the conversion was successful, and removes the original. The directory works as an inbox: files come in one format, leave in another.\nThe conversion itself uses command-line tools installable via Homebrew. cwebp (from the webp package) converts to WEBP; avifenc (from the libavif package) converts to AVIF. Both accept quality parameters that let you adjust the balance between size and visual fidelity — a quality value between 75 and 85 is usually the sweet spot for photographic images, producing dramatically smaller files with no visible artifacts to the naked eye.\nThe script needs to handle a detail that seems minor but causes real problems: files that are still being written. When a browser saves a large image or an application exports a high-resolution PNG, the file appears in the directory before it is complete. If the script fires at that moment and tries to convert a partially written image, the result will be a corrupted AVIF and the original deleted. The simplest protection is to verify the file has stopped growing — compare the size, wait a second or two, compare again — before starting the conversion.\nThe Plist \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.image-optimizer\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/optimize-images.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/Pictures/optimize\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; Unlike the previous scenario, here WatchPaths points at a directory, not a specific file. This makes sense: the goal is to detect the arrival of new files, and file creation is a modification to the containing directory. launchd fires the script when anything changes in ~/Pictures/optimize/, and the script decides what to do — process PNGs and JPGs, ignore everything else.\nThe /opt/homebrew/bin in the PATH is where cwebp and avifenc live after being installed by Homebrew. Without this variable, the script would not find the converters.\nDealing with Loops and Filters There is an obvious trap in this setup that needs to be handled in the script, not the plist: the script itself creates new files in the monitored directory. When it converts photo.png to photo.avif, the AVIF that appears in ~/Pictures/optimize/ is a modification to the directory — and launchd fires the script again. If the script does not filter files by extension before processing them, it will try to convert the AVIF it just created, fail or produce an absurd result, and potentially enter an infinite loop of executions.\nThe cleanest solution is for the script to process exclusively files with .png, .jpg, and .jpeg extensions, ignoring everything else. A find with an extension filter at the beginning of the script solves this in one line. If the find returns nothing, the script exits immediately — the execution was triggered by a change that is none of its business, like the creation of an .avif or the removal of an already processed file.\nA more robust alternative is to use two separate directories: an input where original images are saved, and an output where optimized files are written. WatchPaths monitors only the input, and the script never writes to it — it only reads and deletes. This separation completely eliminates the possibility of loops, at the cost of an extra folder in the structure. Which approach to use depends on the workflow: if the goal is to drag images to a folder and find them optimized in the same place, the extension filter is enough. If the script is part of a larger pipeline where other processes read the output, separating into two directories is safer.\nlaunchd\u0026rsquo;s throttle also appears here, but with different implications than the backup scenario. If you drag ten images to the folder at once, launchd fires the script on the first change and then applies the 10-second interval before allowing a new execution. But this is not a problem in practice: the script does not process a single file per execution — it sweeps the entire directory with find and processes everything it finds. The ten images that arrived together are all converted in the first execution. The throttle would only affect the case where new images keep arriving while the conversion of the previous ones is still running, and even then the next execution would catch everything that was left pending.\nWatchPaths vs. QueueDirectories launchd has a second key for directory monitoring that appears rarely in the documentation and even less in tutorials: QueueDirectories. The syntax is identical to WatchPaths — an array of absolute paths — but the semantics differ in a way that matters for certain workflows.\nWatchPaths fires the job when it detects any modification on the monitored paths. It does not matter what changed or whether the path still exists after the change — the event happened, the job runs. If the script deletes the file that triggered the event, launchd does not care. If the monitored directory is empty after the script finishes, launchd does not care. Its job is to detect the change and execute the command; what happens next is the script\u0026rsquo;s problem.\nQueueDirectories adds a condition: the job only fires when the monitored directory is not empty. And furthermore — if the directory is still not empty when the script finishes, launchd runs the job again. The cycle repeats until the directory is empty, at which point launchd goes back to sleep and waits for the next file arrival. It is, literally, a queue: items come in, the job processes them, and it only stops when the queue is empty.\nThe difference seems subtle but changes how the script needs to be written. With WatchPaths, the script typically sweeps the directory, processes everything it finds, and exits. If something new arrives during execution, launchd fires another execution after the current one finishes (respecting the throttle). With QueueDirectories, the script can afford to process a single item per execution, because launchd will call it again automatically as long as there are pending items. This model simplifies the script — no loops, no find — at the cost of more process invocations.\nFor the image optimization scenario, QueueDirectories would be a natural choice if using the two-directory approach. The input directory works as a queue: images arrive, the script converts one at a time (or all at once, either way), moves the results to the output directory, and launchd keeps calling the script until the input is empty. The advantage over WatchPaths in this case is that there is no risk of missing files that arrived during a long-running conversion — QueueDirectories guarantees the job runs again if there is still work to do.\nFor the SQLite backup scenario, QueueDirectories does not make sense. The database never \u0026ldquo;empties\u0026rdquo; — it is a file that exists permanently and changes content. The queue logic does not apply to a file that is always present. WatchPaths is the right choice when what matters is the modification event, not the presence or absence of content in a directory.\nIn practice, WatchPaths is the general-purpose tool and covers most cases. QueueDirectories shines in pipelines where the directory functions as a disposable inbox — upload processing, format conversion, file ingestion — and where the guarantee that no item is left behind matters more than plist simplicity.\nWhat Is Left for Future Posts This post showed the mechanics of WatchPaths and QueueDirectories, the plists that make everything work, and the pitfalls that need to be handled in the script — throttle, loops, partially written files, the difference between monitoring a file and monitoring a directory. What was deliberately left out were the scripts themselves.\nThe SQLite backup script involves decisions that deserve their own space: the correct order of operations to ensure database consistency, the WAL checkpoint, the staging directory construction, and the rclone configuration with a Backblaze B2 remote. Each of these pieces has its gotchas, and cramming everything into a section of a post about launchd would dilute both launchd and the backup.\nThe image optimization script opens a similar path. The choice between WEBP and AVIF is not obvious — AVIF compresses better but encodes slower, and not every context accepts both formats. The quality parameters in cwebp and avifenc have different behaviors that affect the final result. And the protection logic against incomplete files, which seems trivial described in one paragraph, has more nuance in implementation than the concept suggests.\nThe image conversion script is already done — complete code with encoder fallback, OS detection, and protection against incomplete files. launchd is the trigger; what it fires is where the real complexity lives.\nIf you use Linux, I wrote the equivalent of this post with systemd path units and inotifywait — the same scenarios, adapted for the Linux ecosystem.\n","date":"26/03/2026","lang":"en","tags":["launchd","watchpaths","macos","launch-agents","plist-format","script-logic","sqlite","image-optimization","automation"],"title":"Monitoring Files and Folders with launchd: WatchPaths in Practice","url":"https://devops.sarmento.org/en/posts/monitoring-files-and-folders-with-launchd-watchpaths-in-practice/"},{"categories":["Linux"],"content":"You work remotely, have a server at home, a Raspberry Pi running services, or a machine at the office you need to reach every now and then. The scenario is common and the obvious solution is SSH — already installed, secure, and battle-tested for decades. The problem is that between your machine and the rest of the internet sits a router, a NAT, and possibly an ISP that does not give you a fixed public IP or blocks incoming ports. Suddenly, the most reliable protocol in system administration becomes unreachable from outside your local network.\nThe traditional solutions exist and work: opening ports on the router with port forwarding, setting up DDNS to handle a dynamic IP, building a VPN with WireGuard or Tailscale, or renting a cheap VPS to serve as a bastion host. Each has its place, but all of them add infrastructure, configuration, and in some cases recurring cost — all to solve what should be a simple problem: connecting via SSH to a machine that is on and working but that the outside world cannot reach.\nSSH-J.com offers a different approach: a free public jump host that works as a bridge between you and your machine behind NAT. No account, no signup, no additional software to install. The entire setup comes down to two SSH commands and a systemd service to keep everything running automatically. This post shows how to set it up from scratch, starting from a freshly installed Debian machine all the way to a functional and persistent remote access setup.\nThe Problem: SSH Behind NAT NAT — Network Address Translation — is the mechanism that lets dozens of devices on a local network share a single public IP address. The router maintains a translation table that maps internal connections to ports on the external IP, and everything works transparently for outbound connections: when your machine opens an SSH connection to a server on the internet, the router knows where to route the responses because it created the table entry itself.\nThe problem appears in the opposite direction. An incoming connection — someone trying to SSH into your machine — arrives at the router\u0026rsquo;s public IP, and the router has no way of knowing which internal device to forward it to. There is no entry in the NAT table because nobody from inside initiated that connection. The packet is dropped and, from the perspective of whoever is trying to connect, the machine simply does not exist.\nPort forwarding solves this directly: you configure the router to forward connections on port 22 (or another) to the machine\u0026rsquo;s internal IP. But this is not always feasible. On corporate or coworking networks you do not have access to the router. Many residential ISPs in Brazil use CGNAT — Carrier-Grade NAT — which adds an extra NAT layer on the provider\u0026rsquo;s side, making port forwarding on your router useless because the \u0026ldquo;public\u0026rdquo; IP you see is already a private IP shared with other subscribers. And even when port forwarding works, exposing the SSH port directly to the internet demands extra attention to hardening, fail2ban, and brute force monitoring.\nThe idea behind SSH-J.com is to invert the direction of the connection. Instead of waiting for someone outside to reach your machine, the machine itself opens an outbound connection to SSH-J.com and publishes its SSH port through a reverse tunnel. Since the connection originates from inside the local network, NAT is not an obstacle — the router treats it like any other outbound connection and keeps the path open. When you want to access the machine, you connect to SSH-J.com as a jump host, and it forwards traffic through the already-established tunnel.\nSSH-J.com: a Free Public Jump Host SSH-J.com is a service maintained by ValdikSS, a Russian developer known for censorship circumvention projects and networking tools. The server runs a modified version of Dropbear — a lightweight SSH server common in embedded systems and routers — configured exclusively to allow reverse tunnels and jump host connections. It is not a commercial service, has no paid plan, requires no signup, and stores no user data. The infrastructure is minimal by design: the server exists to forward connections, nothing more.\nHow It Works The mechanism uses two standard features of the SSH protocol available in any modern OpenSSH client: reverse tunnel (-R) and jump host (-J).\nIn the first step, the machine behind NAT opens an SSH connection to SSH-J.com and creates a reverse tunnel. The command is a single line:\nssh myuser@ssh-j.com -N -R my-machine:22:localhost:22 The -N tells SSH not to open a remote shell — the connection exists only to maintain the tunnel. The -R creates the reverse tunnel itself: it instructs SSH-J.com to listen for connections destined to my-machine on port 22 and forward them back to localhost:22 on the machine that originated the connection. The name my-machine is an arbitrary identifier you choose — it can be the hostname, a nickname, anything that makes sense to you.\nIn the second step, when you want to access the machine from anywhere in the world, you use SSH-J.com as a jump host:\nssh -J myuser@ssh-j.com my-machine The -J makes SSH connect first to SSH-J.com and from there establish a TCP connection to my-machine:22, which the server resolves internally through the active reverse tunnel. Authentication happens in two layers: the connection to SSH-J.com (which accepts any user without a password) and the connection to the destination machine (which uses the real credentials configured on it — password or SSH key, just like any normal SSH access).\nThe username on SSH-J.com works as a namespace. Hosts published by a user are tied to them — other usernames cannot access them. This means if someone uses the same machine name as you but with a different username, there is no conflict. The limit is 50 published services per username, more than enough for personal use.\nSecurity: What SSH-J Sees (and What It Does Not) The natural question when routing SSH traffic through a third-party server is: what can that server see? The short answer is that it sees connection metadata but not the content.\nSSH-J.com functions as a TCP relay. It knows that a connection was established between your client and your machine, knows the source and destination IPs, and sees the traffic volume. But the SSH session content is encrypted end-to-end between your client and the destination machine — key negotiation and authentication happen directly between the two endpoints, and the jump host does not have access to the cryptographic material needed to decrypt the traffic. This is a property of how -J operates in OpenSSH: it uses the jump host only as TCP transport, not as an SSH proxy that terminates and re-initiates the connection.\nThat said, you are trusting that the server is what it claims to be. The source code of the Dropbear fork used on SSH-J.com is publicly available on the author\u0026rsquo;s Bitbucket, but you have no way to verify that specific binary is what is running on the server. In practice, the level of trust required is similar to using any VPN or proxy — and considerably less than what is demanded by solutions that install proprietary software on your machine. For accessing a personal server, a Raspberry Pi at home, or a development machine, the risk is acceptable. For accessing production infrastructure with sensitive data, a dedicated VPN or your own bastion host remains the appropriate choice.\nStep-by-Step Setup The entire setup below can be done as a regular user — root is not needed to create the tunnel or to connect. The only step requiring administrator privileges is creating the systemd service, which is in the next section.\nPublishing Your Machine with a Reverse Tunnel First of all, the machine you want to access needs to have the SSH server installed and running. On Debian, if it is not already active:\nsudo apt install openssh-server With local SSH working, the first step is accepting the SSH-J.com host key. This needs to be done once, interactively, before any automation:\nssh myuser@ssh-j.com SSH will show the server\u0026rsquo;s fingerprint and ask if you accept the connection. Type yes. The connection will close immediately because SSH-J.com does not provide an interactive shell — this is expected behavior. What matters is that the entry was saved to ~/.ssh/known_hosts and future connections will not hang waiting for confirmation.\nNow create the reverse tunnel:\nssh myuser@ssh-j.com -N -R my-machine:22:localhost:22 The command produces no output — the cursor sits still and the terminal stays busy. This is expected: the connection is open and the tunnel is active. As long as this terminal stays open, your machine is accessible via SSH-J.com.\nConnecting from Anywhere From any other computer with internet access:\nssh -J myuser@ssh-j.com user@my-machine The myuser is the namespace you chose on SSH-J.com — it must be the same one used in the reverse tunnel. The user is the actual login on the destination machine, which can be different. If the local username is the same as the remote machine\u0026rsquo;s, the user@ part can be omitted:\nssh -J myuser@ssh-j.com my-machine On the first connection, SSH will ask you to accept the destination machine\u0026rsquo;s host key — not SSH-J.com\u0026rsquo;s, which was already accepted. This confirms that encryption is end-to-end: your client is verifying the identity of the final machine, not the intermediary.\nTools that use SSH as transport also work normally through the jump host. To copy a file with scp:\nscp -J myuser@ssh-j.com file.txt user@my-machine:/tmp/ To sync a directory with rsync:\nrsync -avP -e \u0026#34;ssh -J myuser@ssh-j.com\u0026#34; ./folder/ user@my-machine:/home/user/folder/ Configuring ~/.ssh/config on the Client Typing -J myuser@ssh-j.com every time works but gets old. The solution is adding the configuration to ~/.ssh/config on the machine you connect from — your laptop, your Mac, your office computer:\nHost my-machine HostName my-machine User user ProxyJump myuser@ssh-j.com With this entry, access is reduced to:\nssh my-machine And scp and rsync also inherit the configuration automatically, without needing -J:\nscp file.txt my-machine:/tmp/ rsync -avP ./folder/ my-machine:/home/user/folder/ If you have more than one machine published on SSH-J.com, just add a Host block for each one. The namespace on SSH-J.com stays the same — what differentiates them is the host name in each tunnel\u0026rsquo;s -R.\nAutomating with systemd The reverse tunnel works as long as the SSH connection stays open. If the machine reboots, the network drops for a few seconds, or the SSH process dies for any reason, the tunnel disappears and the machine becomes unreachable again. For a quick test that is fine, but for reliable remote access the tunnel needs to maintain itself — come up at boot and reconnect automatically on failure.\nsystemd solves this with a simple service (if you want to understand the systemd unit and dependency ecosystem more deeply, I wrote a post about systemd timers). From here on, commands need to be run as root.\nThe Service Create the file /etc/systemd/system/ssh-tunnel.service:\n[Unit] Description=SSH reverse tunnel via SSH-J.com After=network.target [Service] User=mylocaluser ExecStart=/usr/bin/ssh myuser@ssh-j.com -N -R my-machine:22:localhost:22 Restart=always RestartSec=15 [Install] WantedBy=multi-user.target A few points about this unit file. User= defines which system user will run the SSH process — and consequently which ~/.ssh/known_hosts and SSH keys will be used. This must be the same user with whom you accepted the SSH-J.com host key in the previous section. Restart=always combined with RestartSec=15 makes systemd restart the process whenever it dies, waiting 15 seconds between attempts to avoid hammering the server with rapid reconnections. After=network.target ensures the service only tries to start after the network is configured — without it, SSH would try to connect before having a route to the internet and fail silently.\nAccepting the Host Key Before Activating This step is what trips up anyone who tries to automate the tunnel without having tested manually first. SSH needs the SSH-J.com host key in the known_hosts of the user who will run the service. If it is not there, SSH tries to ask interactively whether you accept the key — but since systemd has no terminal, the question never appears, SSH fails with Host key verification failed, and systemd keeps restarting the process every 15 seconds with no visible result.\nIf you followed the previous section and accepted the host key as the user in the service\u0026rsquo;s User=, this step is already done. If you are not sure, or if the service will run as a different user than the one you used before, run:\nsu - mylocaluser -c \u0026#34;ssh myuser@ssh-j.com\u0026#34; Accept the fingerprint with yes. The connection will close immediately — again, this is expected. What mattered already happened: the host key was saved to that user\u0026rsquo;s ~/.ssh/known_hosts.\nEnabling and Testing With the host key accepted, activate the service:\nsystemctl daemon-reload systemctl enable --now ssh-tunnel The enable makes the service start automatically on the next boot. The --now makes it also start right now, without needing a separate command. Check if it is running:\nsystemctl status ssh-tunnel The output should show active (running) with the SSH process in the process tree. If it shows activating (auto-restart) alternating with failed, the problem is almost certainly the host key — go back to the previous step.\nTo confirm the tunnel is actually functional, go to another machine and try connecting:\nssh -J myuser@ssh-j.com my-machine If access works, the service is ready. From now on the machine will keep the tunnel active permanently, reconnecting on its own after reboots or network drops. You can verify this behavior by rebooting the machine and trying to connect again after it comes back — the tunnel should re-establish automatically within seconds after boot.\nWhen Things Go Wrong The Tunnel Connects but Access Fails The most common error when trying to connect via jump host is:\nchannel 0: open failed: administratively prohibited: stdio forwarding failed This message means SSH-J.com received your connection but found no active reverse tunnel with the host name you requested. In practice, the destination machine is not published — either because the tunnel service is not running, or because the host name in the -R does not match the name you are using in the -J.\nDiagnosis starts on the server side. Connect to the machine (by other means if needed) and check the service:\nsystemctl status ssh-tunnel If it is in failed or looping through auto-restart, the next step is to look at what SSH is saying. Since systemd may not have the journal configured in containers or minimal installations, run the command manually with verbose to see the actual output:\nsu - mylocaluser -c \u0026#34;ssh -v myuser@ssh-j.com -N -R my-machine:22:localhost:22\u0026#34; The output with -v shows each step of the SSH negotiation. The most frequent problems appear in these lines:\nHost key verification failed — the user\u0026rsquo;s known_hosts does not have the SSH-J.com entry. Accept the host key interactively as described in the previous section.\nremote forward failure for: listen — the host name you are trying to publish is already in use by another connection, possibly a previous session that has not yet expired. Wait a few minutes for SSH-J.com to release the name, or choose a different name in the -R.\nConnection refused when trying to connect to the destination machine — the tunnel is active but the machine\u0026rsquo;s SSH server is not running. Check with systemctl status ssh (on Debian, the service is called ssh, not sshd).\nAnother point that causes confusion is the username. The username used on SSH-J.com is a namespace — it ties published hosts to your \u0026ldquo;space.\u0026rdquo; If you created the tunnel with joao@ssh-j.com but try to connect with maria@ssh-j.com, the jump host will not find the machine because it was published under a different namespace. The username on SSH-J.com must be identical on both sides: in the -R of the machine that publishes and in the -J of the client that connects.\nReconnection After Network Drops The systemd Restart=always handles restarting the SSH process when it dies, but there are situations where the TCP connection gets into an intermediate state — the network dropped and came back, but the SSH process has not yet realized the connection was lost. SSH still thinks the tunnel is active while SSH-J.com has already discarded the session. The result is a service that shows as active (running) in systemd but is not actually working.\nSSH has native mechanisms for detecting dead connections. Add these options to the command in the service\u0026rsquo;s ExecStart:\nExecStart=/usr/bin/ssh myuser@ssh-j.com -N -R my-machine:22:localhost:22 \\ -o ServerAliveInterval=30 \\ -o ServerAliveCountMax=3 \\ -o ExitOnForwardFailure=yes ServerAliveInterval=30 makes SSH send a keep-alive packet every 30 seconds. If the server does not respond to three consecutive packets (ServerAliveCountMax=3), SSH terminates the connection. This allows systemd to detect the failure and restart the process within at most 90 seconds plus the configured RestartSec.\nExitOnForwardFailure=yes makes SSH exit immediately if the reverse tunnel cannot be established — for example, if the host name is still held by a previous session. Without this option, SSH keeps the connection open even if the -R failed, and you end up with a running service that is not doing anything.\nWith these three options, the complete unit file becomes:\n[Unit] Description=SSH reverse tunnel via SSH-J.com After=network.target [Service] User=mylocaluser ExecStart=/usr/bin/ssh myuser@ssh-j.com -N -R my-machine:22:localhost:22 \\ -o ServerAliveInterval=30 \\ -o ServerAliveCountMax=3 \\ -o ExitOnForwardFailure=yes Restart=always RestartSec=15 [Install] WantedBy=multi-user.target After editing, reload and restart:\nsystemctl daemon-reload systemctl restart ssh-tunnel This combination of SSH keep-alive with systemd automatic restart covers the vast majority of network failure scenarios. For those who want an additional layer of robustness, autossh is an alternative that monitors the connection more aggressively and reconnects proactively, but in practice the native OpenSSH options combined with systemd already provide sufficient reliability for personal use.\nIf what you need to expose is not SSH but web services — a dashboard, an RSS reader, anything listening on an HTTP port — I wrote a post about Cloudflare Tunnel that solves that scenario with the same outbound connection principle but with automatic HTTPS and a custom domain. And if what you want is to connect all your devices in a private mesh network, Tailscale covers SSH, web services, and everything else without exposing anything to the internet.\n","date":"25/03/2026","lang":"en","tags":["reverse-tunnel","ssh","nat-workaround","systemd"],"title":"SSH Behind NAT? SSH-J.com Solves It.","url":"https://devops.sarmento.org/en/posts/ssh-behind-nat-ssh-jcom-solves-it/"},{"categories":["Self-Hosting"],"content":"There comes a moment when everyone stops and thinks about where their photos are. It usually happens when Google sends that friendly email letting you know your free storage is full — and that for just a few dollars a month you can keep storing your memories on their servers. It is a gentle nudge toward a monthly subscription that, added up over years, costs more than a multi-terabyte external hard drive. But the monetary price is only the most obvious part of the equation. There is a more subtle cost to leaving all your photos, videos, and personal memories in the hands of a company that profits from data — and it is worth talking about that cost before discussing any tool.\nImmich is an open source, self-hosted platform for managing photos and videos that works as a direct alternative to Google Photos. It has a mobile app with automatic backup, facial recognition, smart content-based image search, a timeline, a map with geolocation — all running on your own hardware, with no dependency on any external service. In this post I want to cover not just what Immich does but mainly the reasoning behind hosting your own photos: what data sovereignty means in practice, what the real infrastructure options are (home lab or VPS), and what to expect when you decide to be the owner of your own archive.\nThe Problem with \u0026ldquo;Free\u0026rdquo; When You Are the Product Google Photos launched in 2015 offering unlimited free storage for photos in \u0026ldquo;high quality.\u0026rdquo; The proposition was irresistible and worked exactly as planned: hundreds of millions of people started sending all their images to Google\u0026rsquo;s servers without a second thought. In 2021, the unlimited part ended. Everything began consuming the 15 GB quota shared between Gmail, Drive, and Photos — and anyone who already had years of photos stored was faced with a choice between paying or losing practical access to their own library.\nBut the storage was never truly free. Google\u0026rsquo;s business model depends on understanding who you are, where you go, who you spend time with, and what you do. Photos are a gold mine in that regard. Every image carries EXIF metadata with date, time, GPS coordinates, and camera model. The visual content itself is processed by machine learning models that identify faces, objects, places, and contexts. Google knows you were at that restaurant in March, that you went to the coast over the holiday, that you have a dog of a certain breed, and that you frequent certain places with certain people. None of this information needs to be typed — it is extracted automatically from the archive you sent in yourself.\nThe terms of service authorize Google to use your content to train AI models, improve products, and personalize ads. In practice, your family photos feed the same data pipelines that sell advertising space. There is no conspiracy here; it is all described in the terms that almost nobody reads. The point is that the \u0026ldquo;free\u0026rdquo; service has a price — it just does not show up on the credit card statement.\nThe Invisible Cost of Cloud Storage Beyond the privacy question, there is the problem of control. When your photos live on another company\u0026rsquo;s server, you depend on that company\u0026rsquo;s decisions to access them. Google can change policies, restrict APIs, modify export formats, or simply discontinue a service — as it has done dozens of times with other products. In March 2025, Google restricted the OAuth scopes that tools like gphotos-sync used to download photos, breaking overnight the workflow of anyone who maintained automated local backups of their own images. Those who depended exclusively on Google to store their archive were at the mercy of a unilateral decision over which they had no power.\nThe same reasoning applies to iCloud, OneDrive, Amazon Photos, or any other centralized service. You do not control the product roadmap, you do not negotiate the terms of use, and you have no guarantee the service will continue to exist in the same form five years from now. Meanwhile, data volume only grows: modern phones record in 4K, RAW photos take up tens of megabytes each, and Live Photos add video to every shot. The free tier evaporates quickly, and the monthly subscription that seems cheap at a few dollars turns into hundreds or thousands of dollars over a decade — enough money to build your own infrastructure that nobody can take away from you.\nData sovereignty is not an abstract concept reserved for corporations and governments. At the personal scale, it simply means your files are on hardware you manage, in backups you control, accessible through tools that do not depend on anyone else\u0026rsquo;s goodwill. It is the difference between renting an apartment where the landlord can change the building rules at any time and owning your own home. The same reasoning applies to other areas — even a blog\u0026rsquo;s comments can be self-hosted instead of depending on third parties.\nWhat Immich Is A Real Alternative to Google Photos Immich is an open source platform for photo and video management that runs entirely on your own server. The project was created by Alex Tran, a developer who wanted a private and secure way to store photos of his newborn son without depending on any commercial cloud service. What started as a personal project grew rapidly and today has over 90,000 stars on GitHub, a full-time development team backed by FUTO — an organization dedicated to software that respects the user — and an active community contributing code, translations, and integrations.\nThe comparison with Google Photos is neither exaggeration nor marketing. Immich\u0026rsquo;s web interface is modern, responsive, and organized in a way that anyone accustomed to Google Photos will recognize immediately: a chronological timeline of all your media, grid view, albums, favorites, and a search bar that understands image content. There is a native app for Android and iOS that works like the Google Photos app — open it, see your library, and get automatic backup of everything the camera captures, in the foreground or background. The difference is that every byte leaves the phone and goes straight to the server you manage, without passing through any intermediary.\nThe project is built with TypeScript on the backend, PostgreSQL as the database, Redis for processing queues, and a separate machine learning service in Python that runs the facial recognition and semantic search models. Everything is packaged in Docker containers, which means the entire installation — server, database, queue, and ML — comes up with a single docker compose up -d and can run on any Linux machine with reasonable resources.\nFeatures That Make the Migration Worth It Automatic phone backup is the feature that solves the most immediate problem: the guarantee that every photo taken ends up on your server without any manual action. The app detects new media in the gallery and uploads in the background, even with the screen off. Immich version 2.5 introduced the \u0026ldquo;Free Up Space\u0026rdquo; feature, which lets you remove from the phone photos that have already been sent to the server — exactly like Google Photos does — with a mandatory review step before deletion and sending to the device\u0026rsquo;s native trash, allowing recovery if needed.\nFacial recognition works locally, using machine learning models that run on the server itself. Immich detects and groups faces automatically across the entire library, and you can name each person to later find all their photos with one click. Semantic search uses the CLIP model to index the visual content of images, allowing searches by descriptive terms like \u0026ldquo;beach,\u0026rdquo; \u0026ldquo;dog,\u0026rdquo; \u0026ldquo;birthday,\u0026rdquo; or \u0026ldquo;blue car\u0026rdquo; without any tags having been added manually. All of this intelligence runs on your machine — no data is sent outside your network.\nThe map view plots your photos geographically from EXIF location data, and unlike services that sometimes modify or strip metadata on export, Immich preserves original files intact in a standard folder structure. Non-destructive editing — crop, rotation, and mirroring — saves changes in the database without touching the original file, allowing any modification to be reverted at any time. Multi-user support lets you create separate accounts for each family member, each with their own private library and the ability to share specific albums with each other. There is also full support for iOS Live Photos and Android Motion Photos, preserving both the still image and the video component.\nFor those with photo libraries already organized on disk — years of folders named by date, event, or camera — Immich supports external libraries, importing existing archives without copying the files, only referencing the original path. This means you can point Immich at terabytes of already-organized photos and have them indexed, searchable, and accessible through the app without duplicating any data.\nPrivacy and Data Sovereignty Your Photos Never Leave Your Server When you send a photo to Google Photos, it is transmitted to a datacenter that could be anywhere in the world, processed by automated indexing pipelines, stored on infrastructure shared with billions of other users, and subject to the legal jurisdiction of the country where the physical server is located. You do not choose where your data lives, do not know how many copies exist, do not control who has administrative access to the machines, and have no way to audit what happens to the content after it leaves your phone.\nWith Immich, the chain is short and visible. The phone app uploads directly to your server\u0026rsquo;s address — whether a mini PC in your living room, a NAS in the closet, or a VPS in a datacenter you chose. Files sit in a folder structure on the machine\u0026rsquo;s filesystem, the PostgreSQL database stores metadata and indexes, and the machine learning service processes images locally. If the server is in your home, the data literally never leaves your local network during backup. If it is on a VPS, you know exactly which provider and which geographic region it resides in, and you can choose jurisdictions with data protection laws that make sense for you.\nThis difference sounds technical but has real practical consequences. No employee at any company can view your photos through administrative access. No automated moderation algorithm will flag or remove images that an AI model incorrectly judged as inappropriate. No court in another country can subpoena a provider to hand over your archive without you even knowing about it. The control is yours because the infrastructure is yours.\nLocal AI: Smart Search Without Feeding Someone Else\u0026rsquo;s Datasets One of the strongest arguments in favor of Google Photos has always been search: you type \u0026ldquo;sunset at the beach\u0026rdquo; and it finds the right photo among thousands. This capability exists because Google trained massive computer vision models using — among other sources — exactly the kind of content users upload. It is a self-reinforcing cycle: the more photos users upload, the better the models get, the more useful the product becomes, and the more photos users upload.\nImmich replicates this functionality using the CLIP model, which runs entirely on your server. CLIP creates vector representations of images and search terms, allowing you to find photos by text description without any manual tags having been created. Facial recognition uses detection and clustering models that process each face locally, building clusters of people you can name and search. All of this processing happens on your machine\u0026rsquo;s CPU or GPU cycles. The models are downloaded once and run offline — no image is sent to any external API, no training data leaves your network.\nThe performance is not identical to Google\u0026rsquo;s, and it would be dishonest to say it is. Local models are smaller, consumer hardware is more limited, and the initial indexing of a large library can take hours or days depending on the machine. But search works, facial recognition works, and the speed difference in daily use is small enough not to get in the way. What you gain in return is the certainty that none of your photos are being used to train anyone\u0026rsquo;s next generative AI model.\nEXIF, Metadata, and the Integrity of Your Files Every digital photo carries an invisible layer of information embedded in the file: EXIF data recording the exact date and time of the shot, the GPS coordinates where the photo was taken, the camera model, the lens used, the aperture, shutter speed, and ISO. For anyone who cares about organization and long-term preservation, this metadata is as important as the image itself — it is what allows reconstructing the chronology of an archive, plotting photos on a map, and filtering by equipment.\nCommercial cloud services handle metadata in different and not always transparent ways. Some recompress images on upload, others strip or modify EXIF fields on export, and nearly all convert formats to optimize internal storage. When you download your photos back through Google Takeout, what you receive is not always identical to what you sent — and figuring out exactly what changed requires manual file-by-file comparison.\nImmich stores original files without modification. What you upload is exactly what gets written to the server\u0026rsquo;s disk, with all metadata intact. The non-destructive editing introduced in version 2.5 reinforces this principle: crops, rotations, and mirrors are recorded in the database as instructions without altering the source file. You can download the edited version whenever you want, but the original remains preserved and can be restored at any time. For anyone thinking about long-term archive preservation — decades, not months — this integrity guarantee makes a difference.\nWhere to Host Home Lab: the Server Under Your Desk The option most aligned with the spirit of data sovereignty is running Immich on hardware that is physically in your home. It does not need to be anything grand. A mini PC like the Beelink Mini N150 or an ASRock DeskMini X600 consumes less than 10W at idle, is silent, fits in the palm of your hand, and has more than enough power to run all of Immich\u0026rsquo;s containers without breaking a sweat. Machines like these cost between 300 and 500 dollars, and with an NVMe SSD of a few terabytes you have enough storage for decades of photos at original resolution. If you already have a Synology, QNAP, or Unraid NAS running at home, Immich can be just another container in your stack with no additional hardware.\nThe home lab\u0026rsquo;s biggest advantage is that phone backup happens entirely within your local network. The Immich app connects directly to the machine\u0026rsquo;s IP, files travel over your Wi-Fi, and at no point leave for the internet. Upload speed is your internal network speed — typically gigabit — which makes backing up hundreds of photos and 4K videos a matter of minutes, not hours waiting on your ISP\u0026rsquo;s upload bandwidth. The machine learning processing for facial recognition and semantic indexing also runs locally, and if the machine has a GPU with CUDA support, the process becomes considerably faster.\nTo access Immich from outside the house — at work, traveling, from the phone on the street — there are two practical paths. The first is Tailscale, a mesh VPN that creates an encrypted tunnel between all your devices without requiring any firewall configuration or port forwarding on the router. You install Tailscale on the server and the phone, and Immich becomes accessible via a private address as if it were on the local network, from anywhere in the world. The second path is configuring a reverse proxy or a Cloudflare Tunnel with your own domain and automatic HTTPS certificate, exposing Immich to the public internet. This second path requires more security care but gives more flexibility.\nThe obvious downside of the home lab is that it depends on your residential infrastructure. If the power goes out, the server shuts down. If the router freezes, remote access drops. If the disk fails and no external backup exists, data can be lost. None of this is insurmountable — UPS units are cheap, routers restart themselves, and offsite backups solve the disk failure problem — but it requires you to take on the role of administrator of your own infrastructure. The server will not maintain itself.\nVPS: the Server That Does Not Shut Down When the Power Goes Out For those who do not want to depend on the stability of residential power and internet, running Immich on a VPS is a solid alternative. A VPS is a virtual machine running in a professional datacenter with redundant power, high-capacity network links, and hardware monitored around the clock. Immich needs at least 4 GB of RAM and 2 CPU cores to run comfortably, which translates to plans in the 10 to 30 dollar per month range depending on the provider and the amount of storage.\nInstallation on a VPS is essentially identical to the home lab: SSH into the machine, install Docker, bring up Immich\u0026rsquo;s docker-compose.yml, and configure a reverse proxy with HTTPS. Providers like Hetzner, Contabo, Hostinger, and various European options offer VPS plans with generous storage volumes at reasonable prices. For those concerned about GDPR or wanting data to reside in a specific jurisdiction, choosing a European provider with EU datacenters simplifies the matter: you are simultaneously the data controller and processor, and the data never leaves the server you contracted.\nThe VPS\u0026rsquo;s great advantage is availability. The server is always on, always accessible, with a fixed IP and a network connection whose speed does not depend on the residential plan you contracted from your ISP. Automatic phone backup works from any network — hotel Wi-Fi, 4G on the bus, airport Wi-Fi — without needing a VPN or special tunnel, simply by pointing the app to the server\u0026rsquo;s HTTPS domain.\nThe downside is that your photos now reside in another company\u0026rsquo;s datacenter. You have root access to the virtual machine, control the operating system and installed software, but the hypervisor underneath belongs to the hosting provider. In privacy terms, it is a step back from the home lab — though still miles away from handing everything to Google. The other downside is storage cost. While at home an 8 TB hard drive is a one-time cost equivalent to about two years of VPS, in the cloud every additional terabyte goes on the monthly bill. For very large archives, the VPS bill can get steep.\nHow to Choose Between the Two The choice is not necessarily exclusive, and the most useful question is not which option is better in the abstract but which fits your concrete situation. If you already have a NAS or mini PC at home, stable networking, and do not mind keeping a service running, the home lab offers maximum control with minimal recurring cost. If you live somewhere with frequent power outages, do not want to worry about physical hardware, or need reliable access from anywhere without configuring a VPN, the VPS delivers with predictability and little maintenance.\nIt is also possible to combine both approaches. A setup that appears frequently in the community is running Immich on the home lab for primary use and keeping a copy of the data on a VPS or in object storage like S3, Backblaze B2, or equivalent as an offsite backup. The reverse also works: running Immich on a VPS as the primary server and keeping an encrypted backup on an external drive at home. The important thing is that at least one copy of the data exists outside the location where the primary server operates — but that is a topic for the backup section.\nWhat You Will Need Hardware and Minimum Requirements Immich runs on any Linux machine capable of sustaining Docker and a few reasonably memory-hungry containers. The official recommendation is at least 4 GB of RAM and 2 CPU cores, but in practice 6 GB of RAM and 4 cores make the experience smoother, especially during the initial indexing of a large library, when the machine learning service consumes significant resources processing each image for the first time. Once the library is indexed, daily use is light — uploading new photos, browsing the interface, searching — and the machine sits practically idle between operations.\nFor storage, the math depends on the size of your archive and your habits. Phone photos in HEIC or compressed JPEG take up between 2 and 5 MB each. RAW photos from a dedicated camera range from 25 to 80 MB depending on the sensor. 4K video consumes 300 to 500 MB per minute. Beyond the original files, Immich generates thumbnails and optimized versions for browsing, which adds between 10% and 20% to the total space. An archive of 50,000 phone photos accumulated over ten years occupies roughly 150 to 200 GB with thumbnails included. If there are many 4K videos in the mix, that number climbs fast.\nOn a home lab, an NVMe SSD for the operating system and database combined with a high-capacity mechanical hard drive for the media library is a common and economical configuration. PostgreSQL and Redis benefit from SSD speed, while the photo storage itself does not need low latency — a 4 or 8 TB hard drive serves well. On a VPS, the storage available in the contracted plan is what defines the limit, and it is worth sizing with headroom from the start because migrating to a larger volume later requires downtime and planning.\nIf the machine has an NVIDIA GPU with CUDA support (compute capability 5.2 or higher), Immich can use it to accelerate machine learning processing — facial recognition and semantic indexing become drastically faster. It is not required; everything works on CPU, just more slowly. For anyone building a new home lab who knows they will be indexing a library of tens of thousands of photos, considering a machine with integrated GPU or a modest dedicated card can save many hours of initial processing.\nDocker Compose and the Five-Minute Installation Immich is distributed as a set of Docker containers orchestrated by a docker-compose.yml file maintained by the project. Installation consists of cloning this file, configuring a .env with the storage path and database password, and bringing everything up with docker compose up -d. There is no manual dependency installation, code compilation, or individual service configuration — Compose handles the orchestration between the main server, PostgreSQL, Redis, and the machine learning service.\nThe .env file has few mandatory parameters. The most important is UPLOAD_LOCATION, which defines where media files will be stored on the host\u0026rsquo;s filesystem, and DB_PASSWORD, which should be a strong random string using only alphanumeric characters to avoid Docker parsing issues. The project maintains an example file with default values that work for most cases — just copy, adjust the paths and password, and bring up the containers.\nFor those using Portainer, Dockge, or another container manager with a graphical interface, the process is the same: paste the Compose contents into the stack editor, configure environment variables, and deploy. On Synology NAS or Unraid, the community maintains platform-specific guides that adapt volume paths and permissions to each system\u0026rsquo;s model.\nUpdates follow the same pattern. The project publishes new versions as Docker image tags, and updating is a matter of pulling new images and recreating containers. The entire process takes less than a minute under normal conditions, but it is prudent to back up the database before any update — Immich is under active development and, although the team is careful with schema migrations, surprises happen.\nRemote Access: Tailscale, Reverse Proxy, and HTTPS With Immich running, the next step is ensuring you can access it from outside the local network. If the server is on a VPS with a public IP, just put a reverse proxy in front — Caddy is the simplest option because it obtains and renews HTTPS certificates automatically via Let\u0026rsquo;s Encrypt with no additional configuration. You point a domain to the VPS\u0026rsquo;s IP, configure Caddy to proxy to Immich\u0026rsquo;s port 2283, and within seconds you have working HTTPS access. Nginx also works but requires a bit more manual configuration for the certificate.\nIf the server is on the home lab, the situation is different. Most residential connections in Brazil use CGNAT, which means you do not have your own public IP and cannot simply open ports on the router. Tailscale solves this problem by creating an encrypted mesh network between your devices, regardless of the underlying network topology. You install Tailscale on the server and on each device that needs to access Immich — phone, laptop, tablet — and they all see each other as if on the same local network, with stable IP addresses and end-to-end encrypted traffic. The tailscale serve command exposes Immich on the Tailscale network with automatic HTTPS via MagicDNS, resulting in an address like https://photos.your-tailnet.ts.net accessible from anywhere. If you prefer Immich to be accessible via a public domain without installing anything on client devices, Cloudflare Tunnel is another alternative — it publishes the service with automatic HTTPS using only an outbound connection, without opening ports and without a public IP.\nIn the Immich mobile app, configuration comes down to entering the server URL — whether the public HTTPS domain or the Tailscale address — and authenticating with your user. From there, automatic backup works on any network: home Wi-Fi, mobile data, hotel Wi-Fi. The app is smart enough to respect Wi-Fi-only upload settings if you prefer to save mobile data.\nBackup: the Part Nobody Wants to Think About (but Should) The 3-2-1 Rule in Practice Self-hosting your photos solves the third-party dependency problem but creates a new one: you are the only person responsible for data integrity. There is no longer a Google engineering team replicating your files across three different datacenters on different continents. If your server\u0026rsquo;s disk fails and no backup exists, the archive disappears. Wedding photos, children\u0026rsquo;s first steps, trips that will not happen again — all lost along with the bad sectors of a mechanical drive or the degraded cells of an SSD.\nThe 3-2-1 rule has been around for decades and remains the simplest and most effective framework for data protection: three copies of your files, on two different types of media, with at least one copy offsite. In practice, for someone running Immich on a home lab, this might translate to something like: the primary copy on the server\u0026rsquo;s SSD or HDD, a second copy on an external USB drive plugged into the same machine with automated daily sync, and a third copy on a remote object storage service. For someone running on a VPS, the logic inverts: the primary copy is in the datacenter, and the offsite copy can be an external drive at home receiving periodic syncs via rsync or rclone.\nThe point many people underestimate is that backing up Immich is not just about copying the photos folder. The PostgreSQL database contains all the metadata, search indexes, facial recognition clusters, names assigned to each person, album structure, and information about each user. Losing the photos folder and keeping the database is bad; losing the database and keeping the photos is almost as bad, because all the organization and curation work disappears. Immich offers database backup and restore through the web interface itself since version 2.5, which helps considerably, but the PostgreSQL dump should also be part of the automated backup routine.\nLocal + Offsite Backup with S3 or Equivalent Local backup is the first line of defense and the simplest to implement. An external USB drive connected to the server with a cronjob running rsync once a day solves the second copy question with zero recurring cost. rsync is incremental — on the first run it copies everything, on subsequent runs it copies only what changed — so even multi-terabyte libraries generate small daily transfers after the initial sync. The PostgreSQL dump can be added to the same script: a pg_dump before the rsync ensures the database accompanies the media files in the backup.\nOffsite backup is protection against disasters that affect the server\u0026rsquo;s physical location: fire, flood, theft, a power surge that fries everything plugged into the same outlet. For this third copy, cloud object storage is the most practical and economical option. Amazon S3 on the Glacier Deep Archive class costs pennies per gigabyte per month — a 500 GB archive runs about twenty cents a month. Backblaze B2 is another popular option with similar pricing and no egress fees for small volumes. rclone is the standard tool for this kind of sync: it speaks to virtually any object storage provider, supports client-side encryption before upload, and can be scheduled with a weekly or daily cronjob at whatever frequency makes sense for your data volume.\nThose who prefer not to depend on any commercial service for offsite backup can team up with a friend or family member who also self-hosts: each person runs a script that sends encrypted backups to the other\u0026rsquo;s server via SSH or rclone SFTP. It is the homegrown version of geographic redundancy — your data lives at someone you trust\u0026rsquo;s house, and their data lives at yours, both encrypted end-to-end and unreadable without the key only the owner holds. Tools like restic and borg make this workflow especially practical because they handle incremental, compressed, and encrypted backup natively, without requiring separate steps for each of those functions.\nThe most important thing is that the backup exists, works, and is tested. A backup that has never been restored is an assumption, not a guarantee. Take a moment to verify that the PostgreSQL dump restores correctly in a clean container and that the media files in the backup match what is on the server. Doing this once every few months is enough to sleep soundly.\nLimitations and What to Expect Immich Is Not (Yet) a Finished Product Immich evolves fast — the release cadence is high, the team is responsive, and the commit pace on the repository is impressive. But speed of development also means the software is still in motion. Breakages between versions happen. Database schema migrations occasionally require manual attention. Features appear, change behavior, and sometimes get rewritten between releases. The project\u0026rsquo;s own documentation makes clear that Immich should not be treated as the only copy of your data, and that honesty is a good sign about the team\u0026rsquo;s maturity, but also a reminder that we are talking about software under active construction, not a stable and predictable product like Google Photos.\nIn practice, this means keeping Immich healthy requires a minimum of periodic involvement. Checking logs after an update, reviewing the changelog before pulling a new version, and having the database backup current before any upgrade are habits that need to become routine. None of this is complex or time-consuming — we are talking about five to ten minutes per month under normal conditions — but it is time you would not spend if you were simply using Google Photos.\nThe mobile app for iOS and Android is functional and has improved greatly in recent versions, but it still has rough edges. Background upload sometimes needs a manual nudge on iOS due to Apple\u0026rsquo;s restrictions on background processes. The web interface is attractive and responsive, but anyone coming from Google Photos will notice that some interactions are less polished — drag-selecting multiple photos, transitions between views, scroll speed on very large libraries. These are details, not dealbreakers, and the trend is for them to improve with each release, but it is worth setting expectations before migrating.\nLocal machine learning also has its quirks. Initial indexing of a large library consumes hours on CPU and can take days if the machine is modest. During this period the server runs slower for other tasks. Facial recognition works well in most cases, but cluster accuracy depends on the quality and variety of photos — partially covered faces, bad lighting, or extreme angles generate incorrect groupings that require manual correction. Semantic search via CLIP is impressively useful for generic terms like \u0026ldquo;mountain\u0026rdquo; or \u0026ldquo;party\u0026rdquo; but stumbles on very specific or contextual queries that Google, with its massive models, would handle better.\nWhen Self-Hosting Does Not Make Sense Self-hosting is not for everyone, and pretending it is would be dishonest. If you have no interest in managing infrastructure, do not want to think about backups, are not bothered by Google\u0026rsquo;s terms of use, and the Google One subscription price fits comfortably in your budget, Google Photos remains an excellent product that works without friction. There is no shame in preferring convenience — the overwhelming majority of people make exactly that choice, and for many of them it is the right one.\nImmich is also not the best option for those who need heavy real-time collaboration among many users, for corporate environments with strict compliance requirements demanding certified audits, or for someone with an archive of hundreds of thousands of photos and 4K videos but only a Raspberry Pi with 2 GB of RAM to run it all. The tool scales well for personal and family use but has hardware and scope limits that do not make sense to ignore.\nAnother situation where self-hosting can be more headache than benefit is when residential internet infrastructure is very unreliable. If your connection drops frequently, power is unstable, and there is no budget for a UPS, the home server will spend more time offline than online. In those cases, a VPS solves the availability problem but adds recurring monthly cost — and if the archive is large, that cost can quickly surpass the price of a commercial cloud storage subscription. Every situation is different, and the decision needs to account for concrete reality, not the theoretical ideal.\nIs It Worth It? The answer depends on what you value, and that is not evasiveness — it is the central point of everything discussed so far. If what bothers you is knowing that your family photos feed AI models trained to sell advertising, Immich eliminates that discomfort entirely. If what concerns you is the possibility of a service changing its policies overnight and restricting access to your own archive, running the infrastructure on your own hardware removes that variable from the equation. If what motivates you is simply the satisfaction of opening the app on your phone and knowing that every photo is stored on a server you configured, on a disk you chose, protected by backups you built yourself — then yes, it is very much worth it.\nThe cost of entry is lower than it seems. Those who already have a NAS or mini PC at home can have Immich running in less than an hour without spending anything beyond time. Those who need to buy hardware will invest roughly the equivalent of two to four years of a Google One subscription — after that, the recurring cost is just electricity and, if there is offsite cloud backup, a few cents per month in object storage. Those who prefer the VPS will have a monthly cost but with the tradeoff of not worrying about availability and physical hardware. In any scenario, the investment pays for itself in a reasonable timeframe compared to decades of accumulated subscriptions.\nImmich is not perfect. It is a project under active development, with edges still being smoothed and limitations that do not exist in products from companies with billion-dollar budgets. But it is also one of the most impressive open source projects of recent years in the self-hosting space, with a committed team, a huge community, and a pace of evolution that makes today\u0026rsquo;s software significantly better than six months ago. With each release, the distance between Immich and Google Photos shrinks — and in some respects, like metadata preservation and granular control over data, Immich is already ahead.\nYour photos are the visual record of your life. They are the faces of the people who matter, the places you have been, the moments that will not repeat. Deciding where that archive lives and who has access to it is one of the few digital decisions that truly deserves attention. Immich gives you the option to take that decision back.\n","date":"25/03/2026","lang":"en","tags":["self-hosting","google-photos-alternative","facial-recognition","homelab","vps","cloud-storage","data-sovereignty"],"title":"Immich: Your Photos, Your Server, Your Rules","url":"https://devops.sarmento.org/en/posts/immich-your-photos-your-server-your-rules/"},{"categories":["Static Sites"],"content":"In a previous post, we put together a complete blog with Hugo, GitHub, Cloudflare Pages, and Pages CMS without spending a cent. The stack works, it is fast, and for most personal blogs it will keep working for a long time without asking anything in return. But \u0026ldquo;free\u0026rdquo; does not mean \u0026ldquo;without limits,\u0026rdquo; and understanding where the walls are before you hit them is the kind of thing that saves headaches down the road.\nThis post is the practical companion to that tutorial. Here we will look at the real limits of each free service in the stack, discuss in which scenarios they start to squeeze, and list alternatives for when — or if — that happens. No installation walkthroughs; the idea is to give you the general map so you can make informed decisions.\nThe Price of \u0026ldquo;Free\u0026rdquo; Free services for developers exist for very concrete reasons. GitHub wants you and your team to use the platform until it makes sense to pay for the Team or Enterprise plan. Cloudflare wants your domain going through their network, because more sites mean more data about attacks and more sales arguments for corporate clients. Pages CMS is an open source project maintained by a single developer who needed the tool and decided to share it.\nNone of these motivations are bad — in fact, the alignment of incentives is what makes the model work. GitHub is not going to pull your free repository tomorrow, and Cloudflare is not going to charge for the bandwidth of your recipe blog. The point is that each of these services has limits designed to separate personal and small project use from commercial use at scale, and knowing those limits is part of using the stack with confidence instead of hope.\nIn the following sections, we will go through each piece of the stack — GitHub, Cloudflare Pages, and Pages CMS — with concrete numbers and enough context for you to assess whether any of them are relevant to your case.\nGitHub Free: the Repository Has Walls GitHub\u0026rsquo;s free plan is extraordinarily generous for what most people need: unlimited public and private repositories, unlimited collaborators on public repos, and enough features to run serious projects without paying anything. But generous is not infinite, and the limits that exist are in places that directly affect anyone maintaining a static site.\nRepository and File Size GitHub recommends that repositories stay under 1 GB and strongly insists they do not exceed 5 GB. There is no published hard limit for total size — what happens in practice is that above 5 GB, support reaches out asking you to reduce the size or change your approach. Individual files have a real limit of 100 MB via command line; through the browser, the ceiling drops to 25 MB. Any push with a file over 100 MB is simply rejected.\nFor a Hugo blog, this is rarely a problem. Markdown weighs almost nothing, and the entire repository of a blog with hundreds of posts barely gets past a few dozen megabytes. The risk lives in images. If you version high-resolution photos directly in the repository instead of optimizing them before committing, the Git history accumulates every version of every file, and the repo size grows in a way that is not obvious until you try to clone it and realize you are downloading gigabytes of old JPEGs.\nGit LFS: 1 GB of Storage, 1 GB of Bandwidth For large files that genuinely need to be in the repository, GitHub offers Git Large File Storage. LFS stores the file outside the repo and leaves a lightweight pointer in its place. The free plan includes 1 GB of storage and 1 GB of bandwidth per month — and both limits are tighter than they seem. Each push of a file consumes storage cumulatively (pushing the same 500 MB file twice exhausts the quota), and each download by any person or CI consumes bandwidth. For a personal blog with optimized images, you will probably never need LFS. But it is good to know the limit exists and that if you hit it, pushes are silently rejected.\nGitHub Actions: 2,000 Minutes for Private Repos GitHub Actions is free and unlimited for public repositories using standard runners. For private repositories, the Free plan includes 2,000 minutes per month of execution on Linux runners — equivalent to over 33 hours of CI/CD. If your Hugo repository is public, this limit simply does not apply. If it is private, the 2,000 minutes are more than enough for most workflows: a typical Hugo build takes less than a minute, so you would need to make more than 60 deploys per day to get close to the ceiling.\nWorth noting that Cloudflare Pages already handles build and deploy when you connect the repository, so most Hugo blogs on this stack do not even use GitHub Actions for deployment. The minutes only come into play if you set up additional workflows — linting, tests, image optimization, that kind of thing.\nThe Public vs. Private Repo Detail The choice between a public and private repository changes the limits equation considerably. With a public repo, you get unlimited Actions and full code visibility — which for a blog is not necessarily a problem, since the content will be published anyway. With a private repo, you gain privacy over drafts and configurations but enter the Actions and Packages quota regime. For most personal blogs, keeping the repository public is the simplest decision and the one that leaves the most room within the free plan. If you have reasons to keep the repo private — sensitive content in drafts, configurations you do not want to expose — the Free plan limits are still comfortable for a single site with moderate deploys.\nCloudflare Pages Free: Generous, but with a Ceiling Cloudflare Pages is by far the most generous piece of this stack. Unlimited bandwidth, unlimited requests for static content, automatic SSL, global CDN, unlimited sites — all on the free plan, no credit card required. It is the kind of offer that makes you suspicious, but the logic behind it is solid: Cloudflare wants your domain going through their network, and hosting static sites is computationally cheap enough to serve as a gateway. The limits that exist are not in traffic but in the build process and file scale.\n500 Builds per Month — and It Is per Account, Not per Project Each push to the connected repository triggers a build on Cloudflare. The free plan allows 500 builds per month, and this is the limit that confuses people the most: it is per account, not per project. If you have three sites running on the same Cloudflare account, all three share the same 500-build quota. For a personal blog with one deploy per day, that is about 30 builds per month — comfortable even with multiple sites. The scenario where this gets tight is teams doing rapid iterations with multiple pushes per day across several projects, or anyone using preview deployments extensively during development.\nIn practice, the build limit is more a matter of workflow discipline than a technical restriction. Consolidating changes into fewer commits instead of pushing every comma solves the problem for almost everyone. And if 500 builds per month are not enough, the Pro plan increases it to 5,000 — but at that point you probably already have a commercial reason to pay.\nOne Build at a Time The free plan processes a single build at a time. If you push to two projects simultaneously, the second waits in line until the first finishes. For a Hugo blog, where the entire build takes seconds, this is imperceptible. The limitation becomes relevant for larger sites with heavier build processes — JavaScript frameworks with bundling steps, image optimization in the pipeline, that kind of thing — or for accounts with many projects deploying simultaneously. The Pro plan goes up to 5 concurrent builds, and Business to 20.\n20,000 Files per Site Each site on the free plan can have a maximum of 20,000 files. This counts everything that is deployed: generated HTMLs, images, CSS, JavaScript, fonts, favicons. A medium-sized Hugo blog stays far from this number — even with hundreds of posts and images, you will hardly exceed a few thousand files. The limit starts to matter for large documentation sites with thousands of pages, or for projects that generate many assets per page. Paid plans raise the ceiling to 100,000 files.\nFunctions: the 100,000 Requests/Day Limit Comes from Workers If you use Cloudflare Pages Functions — serverless code running at the edge — requests count against the Workers Free plan quota, which is 100,000 requests per day, resetting at midnight UTC. This limit is shared between Pages Functions and any Workers you have on the same account. For a pure static blog, this does not apply — HTML, CSS, images, and static JavaScript do not consume this quota. It only comes into play if you add dynamic features to the site, like a contact form processed at the edge or a custom API. Even so, 100,000 requests per day is a considerable volume for a blog\u0026rsquo;s auxiliary features.\nPages CMS: Free, Open Source — and the Risks That Come with It Pages CMS occupies a different position from the other pieces of the stack. GitHub and Cloudflare are billion-dollar companies offering free plans as a commercial strategy; Pages CMS is an open source project created and maintained by Ronan Berder, a developer who needed a simple CMS for static sites and decided to share the solution. It is 100% free, MIT-licensed, and can be used either on the hosted version at app.pagescms.org or self-deployed on Vercel or self-hosted. There is no paid plan, no premium tier, no company behind it covering infrastructure costs with revenue from other products.\nThis is simultaneously the greatest quality and the greatest risk of Pages CMS.\nSingle-Developer Project Pages CMS does not have a team, no known public funding, and no organization sustaining its development. This is not a criticism — many of the best open source tools started exactly this way. But it means that the speed of bug fixes, the pace of new features, and the project\u0026rsquo;s very continuity depend on the availability and interest of a single person. If you follow the repository on GitHub, you will see periods of intense activity followed by quieter periods, which is perfectly normal for a project maintained in someone\u0026rsquo;s spare time.\nFor a personal blog, this is an acceptable risk. For a site that a business depends on to publish content regularly, it is the kind of fragility that deserves at least a Plan B.\nTotal Dependence on the GitHub API (and Its Rate Limits) Pages CMS has no database of its own for content. It reads and writes directly to the files in your GitHub repository using the GitHub API. This is elegant — your content never leaves the repository, there is no synchronization to break, and any change made through the CMS is a normal commit that appears in the Git history. But it also means that every operation in the CMS is subject to GitHub API rate limits: 5,000 requests per hour for authenticated users. In practice, editing posts and uploading images on a personal blog does not come close to this limit. The problematic scenario is a team with multiple editors working simultaneously on a repository with many files, where the CMS\u0026rsquo;s navigation and caching can generate a volume of API calls that starts to encounter throttling.\nNo Commercial Support or SLA If something breaks in Pages CMS — and software breaks — there is no support channel to open a ticket and expect a response within a defined timeframe. What exists is the project\u0026rsquo;s GitHub issue queue, where you can report the problem and hope the community or the maintainer responds. For those accustomed to the open source ecosystem, this is standard and works reasonably well. For those coming from the SaaS world with SLAs and chat support, the expectation gap can be frustrating. There is no uptime guarantee for the hosted instance at app.pagescms.org, and there is nobody on call if it goes down on a Sunday night.\nWhat Happens If the Project Is Abandoned This is the question nobody likes to ask about open source projects they use, but that every responsible adult should consider. If the Pages CMS maintainer decides to stop maintaining the project tomorrow, your content stays exactly where it has always been: in your GitHub repository, in Markdown files with YAML front matter. You lose nothing. What you lose is the editing interface — and then you go back to editing content directly on GitHub or in a local text editor, which is not the end of the world but is exactly the inconvenience the CMS existed to solve. The MIT license guarantees that anyone can fork and continue development, but between a project\u0026rsquo;s abandonment and the emergence of a viable fork there is usually a limbo period that can last months.\nThe fact that the content is portable — pure Markdown in a Git repository — is the best protection this architecture offers. You are never locked into a specific CMS, and migrating to any alternative is a matter of reconfiguring the editing tool, not exporting data from a proprietary database.\nWhen These Limits Actually Matter Listing limits out of context makes them seem scarier than they should be. Five hundred builds, twenty thousand files, two thousand minutes — these are numbers that look restrictive until you put a real use case next to them and realize that most people will never come close. What matters is not the absolute number but the relationship between your usage and the available ceiling.\nThe Solo Blogger Will Probably Never Hit Them A personal blog with one author publishing a few posts per month is the scenario this free stack was practically designed for. Do the math: if you publish three times a week and do one push per post, that is about 12 builds per month — 2.4% of the Cloudflare quota. The repository of a blog with five hundred Markdown posts, optimized images, and the entire Hugo theme will barely pass 200 MB. The GitHub API calls from Pages CMS to edit a post and upload an image fit comfortably within a tiny fraction of the 5,000 requests per hour rate limit. In this scenario, the limits are so distant they function as if they did not exist.\nMultiple Sites on the Same Account Change the Equation The math changes when you start stacking projects. The Cloudflare Pages 500-build quota is per account, so five sites with 100 builds each already exhaust the month. The same goes for GitHub Actions minutes on private repos — the 2,000 minutes are shared across all repositories on the account. If you are the kind of person who likes to maintain a personal blog, a portfolio site, a landing page for a side project, and documentation for a tool you wrote, each project alone is lightweight, but the sum can start to push against the limits.\nThe simplest solution is to keep projects in separate accounts when it makes sense, or accept that consolidation has a cost in terms of quota. Another approach is to reduce unnecessary builds: disable automatic deployment of branches that do not need previews, batch changes into fewer commits, and configure ignore patterns in Cloudflare Pages so that pushes that do not alter the site\u0026rsquo;s content do not trigger builds.\nTeams with Multiple Editors and Frequent Deploys The scenario where limits actually start to bite is a team — even a small one — with multiple people editing content and deploying throughout the day. Each save in Pages CMS is a commit, each commit on a connected branch is a build. Three editors actively publishing and reviewing content can generate dozens of builds per day without realizing it. The free plan\u0026rsquo;s single concurrent build means those builds queue up, and the monthly quota of 500 starts to feel less comfortable.\nOn the Pages CMS side, multiple editors browsing and editing simultaneously multiply the GitHub API calls. The 5,000 requests per hour rate limit is per authentication token, so in practice each editor has their own quota — but if the team shares a single installation with one GitHub App, the limit is shared. Additionally, repositories with many files make the CMS work harder to load and cache the directory structure, which can result in a slower experience and cache bugs that have already been reported in the project\u0026rsquo;s issue queue.\nFor teams fitting this profile, the free stack still works, but it demands more attention to workflow. And it is exactly the point where it makes sense to evaluate whether a paid plan on one end — Cloudflare Pro for more builds, or a CMS with commercial support — does not pay for itself in saved time and frustration.\nAlternatives at a High Level If you have read this far and concluded that some limit of the free stack is relevant to your case, the good news is that each piece can be replaced independently. The static site architecture with a Git repository is modular by nature — you can swap hosting without swapping the CMS, swap the repository without swapping the generator, and so on. What follows is not a detailed analysis of each alternative but a map of options so you know where to look.\nFor the Repository: GitLab, Codeberg, Self-Hosted Gitea If GitHub Free\u0026rsquo;s limits are the problem — repository size, CI minutes, or simply a preference for not depending on a specific platform — mature alternatives exist. GitLab offers unlimited private repositories with 400 CI/CD minutes per month on the free plan and has its own Pages system for static hosting. Codeberg is a nonprofit alternative based on Forgejo, with no artificial CI limits and an explicitly free software philosophy. For those who want full control, Gitea or Forgejo can be installed on any server — a cheap VPS is more than enough to host Git repositories with integrated CI.\nThe main consideration when switching Git platforms is the cascading effect on the rest of the stack. Pages CMS depends specifically on the GitHub API, so moving the repository to GitLab means also switching the CMS. Cloudflare Pages integrates natively with both GitHub and GitLab, so that part of the migration is straightforward.\nFor Hosting/Deploy: Netlify, Vercel, GitHub Pages, Caddy/Nginx + Your Own CI Cloudflare Pages is not the only option for hosting static sites with automatic deploy from a Git repository. Netlify offers a free plan with 300 build minutes per month and 100 GB of bandwidth — less generous than Cloudflare on bandwidth, but with a plugin ecosystem and features like forms and identity that can simplify certain use cases. Vercel has a free plan aimed at JavaScript frameworks but works perfectly with Hugo and offers 100 GB of bandwidth per month. GitHub Pages itself is a solid option for Jekyll and Hugo sites, with generous bandwidth and deploy straight from the repository — the main limitation is the lack of custom builds without GitHub Actions and more limited support for advanced routing configurations.\nFor those who want to leave managed platforms entirely, the combination of a server with Caddy or Nginx, a lightweight CI like Drone or Woodpecker, and deploy via rsync or webhook offers absolute control. The cost is a VPS at five or ten dollars a month and the time for setup and maintenance — which for a sysadmin is trivial, but for someone who set up their blog following a tutorial can be a considerable leap in complexity.\nFor the CMS: Decap CMS, Sveltia CMS, TinaCMS, Publii Pages CMS is one of several CMS options for Git-based static sites, and if the risks of depending on a single-maintainer project do not sit well with you, alternatives exist with different profiles of maturity and support.\nDecap CMS, formerly Netlify CMS, is the veteran of the space. It is open source, works as a single-page app running on the site itself at an /admin route, and commits directly to the repository. It has a large community and is the most battle-tested in production, but development has slowed in recent years and the editing experience is not the most modern. Sveltia CMS aims to be a drop-in replacement for Decap with a faster and more polished interface while maintaining compatibility with the same configuration — worth considering as a direct alternative if Decap works for your workflow but the interface bothers you.\nTinaCMS is the most ambitious option in the group. It offers real-time visual editing on the site itself, supports Markdown and MDX, and has both a self-hosted version and a cloud service with a free plan. The trade-off is setup complexity, which is significantly higher than Pages CMS, and a stronger dependency on the JavaScript ecosystem and frameworks like Next.js — although it works with Hugo, the integration is not as seamless.\nPublii follows an entirely different philosophy: it is a desktop application for Windows, Mac, and Linux that works as a local CMS. You edit content on your machine, Publii generates the static site, and deploys to GitHub Pages, Netlify, S3, or any server via FTP. It does not depend on any API, does not need internet to edit, and eliminates the entire web CMS layer. The downside is that content lives on the machine where Publii is installed, and the collaborative workflow with multiple editors is not its strong suit — in fact it is practically impossible to maintain a blog with multiple authors on Publii.\nIn every case, the fact that the content is Markdown with front matter in a Git repository means that migrating between these tools is a matter of reconfiguration, not data conversion. You chose an architecture where content is independent of the editing tool, and that is the most valuable decision in the entire stack.\nMy Take: Start with Everything Free After listing limits, risks, and alternatives, it would be easy to close this post with a cautious recommendation — something like \u0026ldquo;evaluate carefully before choosing\u0026rdquo; or \u0026ldquo;every case is different.\u0026rdquo; But the truth is simpler than that: for anyone starting a blog or personal site, the free stack with Hugo, GitHub, Cloudflare Pages, and Pages CMS is the best choice available today, and it is not a compromise.\nThe limits exist, but they are designed for a usage volume that the vast majority of personal sites will never reach. Cloudflare\u0026rsquo;s unlimited bandwidth eliminates the most common worry for anyone hosting their own content. The 500 monthly builds are more than enough for any reasonable publishing pace. GitHub Free offers everything a blog repository needs without charging a thing. And Pages CMS, despite the inherent risks of a single-maintainer project, solves the real problem of editing content without needing to open a terminal.\nMost importantly, this stack does not lock you in. Your content is Markdown in a Git repository — the most portable format that exists for text on the web. If tomorrow Pages CMS is abandoned, you switch CMSes. If Cloudflare changes the free plan terms, you move the site to Netlify in an afternoon. If GitHub does something you disagree with, your files are on your local disk and can go to any other Git service with a git remote set-url. Every decision in this architecture is reversible, and that is worth more than any SLA guarantee.\nStart with everything free. Publish. Write. When — and if — any limit becomes real in your actual usage, you will know exactly which piece to swap and why, because the problem will be specific and not hypothetical. Optimizing before you have a problem is premature engineering, and premature engineering is the most efficient way to never publish anything.\nIf one of the gaps you notice first is the lack of native comments, I already wrote about how to solve that with a lightweight, self-hosted solution with no tracking.\n","date":"24/03/2026","lang":"en","tags":["self-hosting","hugo","static-site","limits","github","pages-cms-limits","cloudflare"],"title":"The Dark Side of Free Website: Limits and Alternatives for Hugo + GitHub + Cloudflare Pages + Pages CMS","url":"https://devops.sarmento.org/en/posts/o-lado-b-do-site-gratis-limites-e-alternativas-para-hugo-github-cloudflare-pages-pages-cms/"},{"categories":["macOS"],"content":"In the previous post I showed how systemd timers replace cron on Debian and Ubuntu servers with concrete advantages: integrated logging, missed execution recovery, declarative dependencies, and resource control. The logic is compelling and the migration is straightforward — as long as you are on a system running systemd. But if your daily routine includes a Mac, it is a different story.\nmacOS has its own scheduling system, predating systemd and built on a different philosophy. It is called launchd, it has been around since Mac OS X Tiger in 2005, and it is responsible for practically everything that runs in the background on the system — from internal Apple services to that Spotify updater you never asked to install. Despite being the official and recommended way to schedule tasks on a Mac, launchd lives in a kind of blind spot: people coming from Linux tend to reach for cron out of reflex, and Mac users without a sysadmin background do not even know the option exists.\nThis post shows how to use launchd to schedule a command that runs every day at 7 AM on my Mac — fuqu telegram, which generates a daily briefing of my tasks and sends it to Telegram. The walkthrough serves as a model for any script or command you want to schedule reliably without depending on an open terminal, without cron, and without third-party apps.\ncron Works on Mac. But That Is Not How It Is Done. cron exists on macOS. You can open Terminal, type crontab -e, and schedule a job exactly as you would on a Linux server. The daemon is there, it works, and nobody removed it. So why not use it?\nThe first reason is that Apple has considered cron deprecated in favor of launchd since 2005. The daemon remains present for compatibility, but it receives no improvements, does not integrate with modern system mechanisms, and does not appear in any official documentation as the recommended way to schedule tasks. In practice, it works until the day a macOS update changes something and it stops working — and when that happens, there will be no fix because cron has not been an Apple priority for two decades.\nThe second reason is more immediate: cron on macOS does not know how to deal with the way a Mac actually operates. Macs sleep, close their lids, stay off at night, and turn on in the morning. If a cron job was scheduled for 3 AM and the Mac was asleep at 3 AM, the execution is lost without warning — the same problem cron has on Linux, but made worse by the fact that a personal laptop is off or asleep far more often than a server. launchd has a native mechanism to detect missed executions and fire them as soon as the system wakes up, which by itself justifies the switch for any task that needs to run daily and reliably.\nThe third reason is the execution environment. cron on macOS runs with a minimal PATH and no access to your shell environment. Scripts that depend on Homebrew binaries in /opt/homebrew/bin, on Python virtual environments, or on environment variables set in .zshrc simply cannot find what they need when run via cron. The classic solution — putting the full PATH on the first line of the crontab or wrapping everything in a script that sources the profile — works, but it is the kind of workaround you need to remember exists every time you add a new script. launchd offers a declarative way to define environment variables per job, without depending on the user\u0026rsquo;s shell.\nNone of this means cron will explode if you use it on a Mac. For a simple script that runs while the laptop is open and awake, it gets the job done. But if you want scheduling that survives sleep, with predictable logging and integration with the operating system, launchd is the right tool — and the one Apple expects you to use.\nlaunchd: the systemd of macOS LaunchDaemons vs. LaunchAgents launchd is the PID 1 process on macOS — the first process the kernel starts and the parent of all others. In that sense, it occupies exactly the same role as systemd on Linux: it manages services, controls boot dependencies, and handles the lifecycle of everything running in the background. The difference is that launchd has been doing this since 2005, nearly a decade before systemd became the standard on Linux distributions.\nTasks managed by launchd fall into two categories that you need to understand before creating anything: LaunchDaemons and LaunchAgents.\nLaunchDaemons are system services. They run as root, start during boot before any user logs in, and have no access to the graphical session. Their plist files live in /Library/LaunchDaemons/ (for third-party daemons) or /System/Library/LaunchDaemons/ (for Apple\u0026rsquo;s own, which you should not touch). They are the equivalent of services you would place in /etc/systemd/system/ on Linux — system-level backups, network services, hardware monitoring.\nLaunchAgents are user tasks. They run with the permissions of the logged-in user, start when the user logs in, and have access to the graphical session environment. They exist in two places: /Library/LaunchAgents/ for agents installed by third-party applications that apply to all users, and ~/Library/LaunchAgents/ for personal agents that belong exclusively to your account. That last directory is where everything you create for personal use will live — and it is the closest equivalent to systemd\u0026rsquo;s user timers in ~/.config/systemd/user/, with a practical advantage: on macOS, personal LaunchAgents work automatically while the user is logged in, without needing any equivalent to loginctl enable-linger.\nFor scheduling a personal command like fuqu telegram, the choice is straightforward: LaunchAgent in ~/Library/LaunchAgents/. No root needed to create it, no root needed to load it, and it runs in the context of your user session.\nThe plist Format and the Scheduling Logic While systemd uses .ini files with sections and directives, and cron uses a text line with five fields, launchd uses plist files — property lists in XML format. The initial reaction from anyone seeing a plist for the first time is usually surprise: it is verbose, full of tags, and seems disproportionate for what it is doing. But the format is the standard Apple uses for configuration throughout the entire operating system, and after writing the first file the pattern becomes predictable.\nA LaunchAgent plist is an XML dictionary with keys describing what to run, when to run it, and how to handle output. Only two keys are mandatory: Label, the agent\u0026rsquo;s unique identifier (by convention in reverse domain format like com.janio.fuqu-telegram), and ProgramArguments, an array with the command and its arguments. The minimum needed to define a task fits in a few lines — all the XML ceremony is syntactic, not conceptual.\nScheduling is configured with the StartCalendarInterval key, which accepts a dictionary with fields for hour, minute, day of month, month, and day of week. The logic is similar to cron: you specify only the fields that matter and the rest default to \u0026ldquo;any value.\u0026rdquo; A dictionary with only Hour and Minute defined means \u0026ldquo;every day at that time\u0026rdquo; — the direct equivalent of 0 7 * * * in cron or *-*-* 07:00:00 in systemd\u0026rsquo;s OnCalendar.\nThe difference that matters in practice is the behavior when the Mac is asleep or off at the scheduled time. launchd detects that the execution was missed and fires it as soon as the system wakes up or restarts. This behavior is the default — no directive equivalent to systemd\u0026rsquo;s Persistent=true is needed. If the agent is loaded and the time has already passed, the execution happens at the next opportunity. For anyone scheduling tasks on a laptop that is not powered on 24 hours a day, this guarantee alone is worth the transition from cron to launchd.\nOther useful keys that will appear in the practical example include StandardOutPath and StandardErrorPath for directing output to log files, EnvironmentVariables for defining environment variables available during execution, and WorkingDirectory for setting the process\u0026rsquo;s working directory. Everything declarative, everything in the same file, no wrappers or profile sourcing needed.\nPractical Case: FUQU Telegram at 7 AM What the Command Does FUQU is a personal task manager that runs in the terminal, built in Python with Textual and SQLite. Among other things, it has a telegram subcommand that generates a daily briefing — a summary of pending, overdue, and scheduled tasks for the day — and sends it to a Telegram bot. The briefing is generated by a language model (local LM Studio with fallback to the Cerebras API), formatted in Markdown, and delivered as a message in the bot\u0026rsquo;s chat. The result is that every day at 7 AM, before I open the laptop, the day\u0026rsquo;s summary is already waiting in Telegram on my phone.\nIn the terminal, the command that makes this happen is:\ncd ~/Dropbox/fuqu \u0026amp;\u0026amp; .venv/bin/python -m fuqu telegram The project lives in ~/Dropbox/fuqu, uses a local virtualenv in .venv, and is run as a Python module. The cd to the project directory is necessary because FUQU resolves the SQLite database path relative to the working directory. This combination of cd plus the absolute path to the Python interpreter inside the venv is the kind of thing that works perfectly in the interactive shell but breaks when executed outside the user\u0026rsquo;s context — which is exactly what happens in a LaunchAgent.\nThe Wrapper Script launchd does not execute commands through a shell. It calls the binary directly, without going through .zshrc, without expanding aliases, without inheriting the interactive session\u0026rsquo;s PATH. For this reason, the cleanest approach is to wrap the command in a dedicated shell script that handles the execution environment.\n#!/bin/bash cd ~/Dropbox/fuqu || exit 1 .venv/bin/python -m fuqu telegram The script does two things: changes to the project directory (aborting if the directory does not exist) and runs the command with the absolute path to Python inside the virtualenv. It does not depend on aliases, does not depend on PATH, does not depend on anything that exists only in the shell\u0026rsquo;s interactive session.\nThe file can live anywhere reasonable. I keep personal scripts of this kind in ~/bin/, a directory that already exists in my interactive PATH but that does not matter here — the plist will reference the absolute path regardless:\nmkdir -p ~/bin cat \u0026gt; ~/bin/fuqu-telegram.sh \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; #!/bin/bash cd ~/Dropbox/fuqu || exit 1 .venv/bin/python -m fuqu telegram EOF chmod +x ~/bin/fuqu-telegram.sh Before involving launchd, it is worth testing the script in isolation to make sure it works outside the interactive shell context:\n/usr/bin/env -i HOME=\u0026#34;$HOME\u0026#34; ~/bin/fuqu-telegram.sh The env -i clears all environment variables, simulating the minimal environment that launchd will provide. If the briefing arrives on Telegram, the script is ready.\nThe plist File The LaunchAgent is an XML file in ~/Library/LaunchAgents/. By convention, the name follows the reverse domain format — in my case, com.janio.fuqu-telegram.plist:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.fuqu-telegram\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/bin/fuqu-telegram.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StartCalendarInterval\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Hour\u0026lt;/key\u0026gt; \u0026lt;integer\u0026gt;7\u0026lt;/integer\u0026gt; \u0026lt;key\u0026gt;Minute\u0026lt;/key\u0026gt; \u0026lt;integer\u0026gt;0\u0026lt;/integer\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/.local/log/fuqu-telegram.out.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/.local/log/fuqu-telegram.err.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;WorkingDirectory\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/Dropbox/fuqu\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; A few notes on each key:\nThe Label is the agent\u0026rsquo;s unique identifier within launchd. It must match the file name without the .plist extension. It is what appears in the output of launchctl list and what you use to interact with the agent via command line.\nProgramArguments takes an array with the absolute path to the script. Paths with ~ are not expanded by launchd — always use the full path starting from /Users/. If the command needed additional arguments, each one would be a separate \u0026lt;string\u0026gt; inside the array.\nStartCalendarInterval with only Hour and Minute defined triggers the agent every day at 7:00 AM. Omitting the day, month, and day-of-week fields is equivalent to putting * in each — the default is \u0026ldquo;any value.\u0026rdquo; If the Mac is asleep at 7 AM, launchd runs the agent as soon as the system wakes up.\nThe StandardOutPath and StandardErrorPath paths direct output to log files. The directory must exist before the first execution — launchd does not create directories automatically. Without these keys, all script output disappears silently.\nWorkingDirectory is redundant with the cd in the wrapper script, but serves as an additional safeguard. If for some reason the cd inside the script fails unexpectedly, the process will still be in the correct directory.\nBefore loading the agent, create the log directory:\nmkdir -p ~/.local/log Loading and Testing With the plist in place and the log directory created, the agent needs to be registered with launchd. macOS offers two sets of commands for this: the older launchctl load and launchctl unload, and the newer launchctl bootstrap and launchctl bootout. The older commands still work and are simpler, so that is what I use:\nlaunchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist To verify the agent was loaded:\nlaunchctl list | grep fuqu The output shows three columns: the PID (a - if the agent is not running at this moment), the last exit code (0 for success), and the label. Seeing the label in the list confirms that launchd recognized the agent and is monitoring the schedule.\nTo test without waiting until 7 AM, you can trigger the agent manually:\nlaunchctl start com.janio.fuqu-telegram The command returns immediately — execution happens in the background. To see the result, check the output log:\ncat ~/.local/log/fuqu-telegram.out.log And the error log, which should be empty if everything went well:\ncat ~/.local/log/fuqu-telegram.err.log If the briefing arrived on Telegram and the logs show no errors, the agent is working. From now on, every day at 7 AM — or the moment you open the laptop if it was asleep at 7 AM — the day\u0026rsquo;s task summary will appear on Telegram automatically, with no open terminal, no manual intervention.\nIf you need to change the time or any other setting, the cycle is: unload the agent, edit the plist, reload:\nlaunchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist # ... edit the file ... launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist To remove the agent permanently, just unload it and delete the plist file. Without the file in ~/Library/LaunchAgents/, it will not be loaded on the next login.\nWhat launchd Gives You for Free StartCalendarInterval and Missed Executions In the post about systemd timers, the Persistent=true directive was presented as a concrete advantage over cron: if the system was off at the scheduled time, the timer fires the execution as soon as possible after boot. In launchd, this behavior does not need to be enabled — it is the default. If the Mac was asleep or off at 7 AM, the agent runs as soon as the system wakes up, with no additional key in the plist.\nThe mechanism is simple: launchd compares the StartCalendarInterval with the current time and the timestamp of the last execution. If it detects that one or more intervals were missed, it triggers the agent immediately. Unlike systemd, which explicitly writes the last execution timestamp to disk when Persistent=true is active, launchd does this tracking internally as part of its normal operation.\nIn practice, this means the 7 AM briefing arrives on Telegram even if I only open the laptop at 9 AM. The delay is the time the system takes to wake up and load the LaunchAgents — a matter of seconds. For daily tasks like this one, the behavior is exactly what you would expect: the execution happens once per day, at the defined time or at the first opportunity after it, with no duplication and no loss.\nThere is a subtlety worth knowing: if the Mac was off for several days, launchd fires the agent only once upon waking, not once for each missed day. For a daily briefing this is the correct behavior — receiving five stale briefings at once would be pointless. But for tasks where each missed execution matters individually, like log rotations or metrics collection, this behavior needs to be taken into account.\nLogging with stdout and stderr cron on macOS inherits the same logging problem it has on Linux: job output goes to the user\u0026rsquo;s local email via the system\u0026rsquo;s MTA, which on most personal Macs is not configured. The result is that output simply vanishes, and figuring out whether a job ran — and what it did — becomes a guessing game.\nThe StandardOutPath and StandardErrorPath keys in the plist solve this directly. All standard output from the process goes to one file, all error output goes to another, both with automatic append. No redirection in the script, no logger, nothing beyond the two lines in the plist.\nThe separation between stdout and stderr is useful in practice. The output file contains the command\u0026rsquo;s normal result — in the case of fuqu telegram, confirmation that the briefing was generated and sent. The error file captures Python exceptions, stack traces, connection problems with the LLM or Telegram API, and any other diagnostics the command writes to stderr. When everything works, the error file stays empty, and the fact that it has content is itself a signal that something needs attention.\nOne limitation compared to systemd is that launchd has no equivalent to journald. Logs are plain text files that grow indefinitely. Managing rotation is up to you — either with a second LaunchAgent that truncates or rotates the files periodically, or with newsyslog, which macOS includes natively and which can be configured to rotate any log file. For tasks that run once a day and produce a few lines of output, growth is negligible and rotation can be something you handle once a year by manually deleting old files. For more verbose or frequent tasks, it is worth setting up rotation from the start.\nEnvironment Variables and PATH When you open Terminal on a Mac and type a command, the shell loads .zshrc (or .bash_profile, depending on the setup), sets the PATH with Homebrew directories, exports custom environment variables, and configures everything your workflow needs. None of that exists in a LaunchAgent\u0026rsquo;s context. launchd runs the process with a minimal environment: HOME, USER, TMPDIR, and little else. The default PATH contains only /usr/bin:/bin:/usr/sbin:/sbin — Homebrew in /opt/homebrew/bin, tools installed via pip or cargo in ~/.local/bin, and any other custom directory simply do not exist.\nThe wrapper script works around this problem for FUQU\u0026rsquo;s specific case because it uses the absolute path to Python inside the virtualenv, without depending on PATH for anything. But for agents that call Homebrew binaries or depend on specific environment variables, the plist offers the EnvironmentVariables key:\n\u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;LANG\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;en_US.UTF-8\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; Each variable is a key-value pair inside the dictionary. The PATH can be extended to include any necessary directory, and variables like LANG ensure character encoding behaves the same way as in the interactive terminal — something that matters when the command produces output with accented or special characters.\nThe declarative approach has an advantage over cron\u0026rsquo;s pattern of putting PATH=... at the top of the crontab: each agent has its own set of variables, isolated from the others. An agent that needs a specific Python version can point to a different PATH than one that uses Node, without conflict. In cron, the PATH set in the crontab applies to all jobs for that user.\nTroubleshooting: When the Agent Does Not Run launchctl and the Commands You Will Use launchctl is the command-line interface to launchd, and in practice you will use a handful of subcommands repeatedly. It is worth having them handy because the launchctl man page is extensive and not always friendly.\nTo see all agents loaded in your user session:\nlaunchctl list The output has three columns: PID, last exit code, and label. A PID showing - means the agent is not running at this moment, which is normal for agents based on StartCalendarInterval that execute and exit. An exit code of 0 indicates success on the last run; any other number points to a problem.\nTo check the status of a specific agent without filtering with grep:\nlaunchctl print gui/$(id -u)/com.janio.fuqu-telegram The print subcommand shows detailed information: the agent\u0026rsquo;s current state, the plist path, the last exit code, log paths, and scheduling conditions. The gui/$(id -u) prefix identifies the logged-in user\u0026rsquo;s domain — it is the equivalent of saying \u0026ldquo;the agent that belongs to me, not to the system.\u0026rdquo;\nTo trigger a manual execution outside the scheduled time:\nlaunchctl start com.janio.fuqu-telegram To unload an agent (removes it from launchd but does not delete the file):\nlaunchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist To reload after editing the plist:\nlaunchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist There is no launchctl reload. Unloading and loading again is the standard workflow, and it must be done in that order — trying to load an agent that is already loaded results in an error.\nCommon Errors and Where to Look The most frequent error when creating a LaunchAgent for the first time is the agent simply doing nothing. No message, no log, no sign of life. The diagnosis follows a predictable sequence.\nThe first step is to confirm the agent is loaded. If launchctl list | grep fuqu returns nothing, launchd does not know the agent exists. The most common causes are the plist file outside the correct directory (it must be in ~/Library/LaunchAgents/, not ~/LaunchAgents/ or any other variation), incorrect file permissions (it should belong to your user, with 644 permissions), or malformed XML. The plutil command validates the plist syntax without loading it:\nplutil -lint ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist If plutil reports an error, the problem is structural in the XML — an unclosed tag, a wrong type, a key outside the main dictionary. plutil indicates the line and nature of the error, which is usually enough to find the problem.\nThe second step, if the agent is loaded but the exit code is not zero, is to check the logs. If StandardOutPath and StandardErrorPath are configured in the plist, the error file\u0026rsquo;s content usually reveals the problem immediately. If the log files exist but are empty, the process may be terminating before producing any output — which points to a problem in the command itself or the wrapper script.\nIf the log files were not even created, the most likely cause is that the parent directory does not exist. launchd does not create intermediate directories — if ~/.local/log/ does not exist, logs simply are not written, and launchd does not warn that this happened. Creating the directory and triggering the agent manually with launchctl start usually resolves it.\nThe third failure pattern is the command working in Terminal but failing in the LaunchAgent. This is almost always an environment problem. The script depends on something that exists in the shell\u0026rsquo;s interactive session but not in launchd\u0026rsquo;s context — a PATH that includes Homebrew, an environment variable exported in .zshrc, a service that only runs when Terminal is open. The env -i test described in the previous section exists to catch this kind of problem before involving launchd, but if the agent is already created and failing, the diagnosis is the same: run the wrapper script with env -i HOME=\u0026quot;$HOME\u0026quot; and see what breaks.\nOther errors that appear less frequently include the Label in the plist not matching the file name (launchd is tolerant of this in most cases but it can cause unexpected behavior), ProgramArguments using ~ instead of the absolute path (launchd does not expand tilde), and the wrapper script lacking execute permission (chmod +x fixes it). In all these cases, launchctl print for the agent usually has the information needed to reach the cause — it is worth getting used to it as the first diagnostic tool instead of trying to guess what went wrong.\nComparing with systemd Timers Anyone who read the previous post and made it this far has probably noticed that launchd and systemd timers solve the same fundamental cron problems — logging, missed executions, environment variables, dependencies — but with different approaches that reflect their respective operating systems\u0026rsquo; philosophies.\nThe separation of concerns is where the similarity is most direct. systemd splits the task into two files: a .service defining what to run and a .timer defining when. launchd puts everything in a single plist, with keys for the command and for the schedule in the same file. In practice, the systemd approach is more flexible — the service can be tested, monitored, and controlled independently of the timer, and the same service can be triggered by multiple timers or by other system events. The launchd plist is more self-contained and simpler to move between machines, but it couples the task definition to its schedule.\nLogging is where systemd has a clear advantage. journald centralizes all output from all services in a structured logging system, with filters by unit, time, and priority, and with automatically managed persistence. launchd writes to plain text files that you need to rotate yourself. For a personal agent that runs once a day, the difference is irrelevant. For a server with dozens of scheduled tasks, the absence of a centralized journal on macOS is felt.\nMissed execution recovery works in both systems but through different mechanisms. In systemd it is opt-in via Persistent=true — if you do not declare the directive, missed executions during downtime are ignored, exactly like cron. In launchd, recovery is the default behavior and cannot be disabled. Apple\u0026rsquo;s choice makes more sense for laptops that sleep and wake constantly; systemd\u0026rsquo;s makes more sense for servers where the administrator may have good reasons for not wanting a missed task to run outside its scheduled window.\nResource control and sandboxing is systemd\u0026rsquo;s exclusive territory. Directives like CPUQuota, MemoryMax, ProtectHome, and PrivateTmp have no direct equivalent in launchd for user LaunchAgents. macOS has its own sandboxing mechanisms (App Sandbox, seatbelt profiles), but they are aimed at applications distributed through the App Store, not at personal scripts in ~/Library/LaunchAgents/. In practice, a LaunchAgent runs with the user\u0026rsquo;s full permissions, without any additional restrictions — similar to what cron does on both systems.\nScheduling syntax is more expressive in systemd. The OnCalendar format accepts ranges like Mon..Fri, repetitions like *:0/15, multiple directives stacked in the same timer, and the ability to validate everything beforehand with systemd-analyze calendar. launchd\u0026rsquo;s StartCalendarInterval is functional but limited: it accepts fixed values for each field, with no ranges or repetitions in the same dictionary. Scheduling a task for weekdays at 6 PM requires five separate entries in the plist (one per day), while in systemd it is a single line. For simple schedules like \u0026ldquo;every day at 7 AM,\u0026rdquo; both solve it with equal ease.\nWhere launchd has no rival is integration with the macOS lifecycle. It natively understands system sleep and wake, knows when the user has logged in and out, and can condition an agent\u0026rsquo;s execution on events like a volume being mounted or a connection to a specific network. systemd timers can depend on system targets and units, but the vocabulary of available events on Linux is different — geared toward servers that stay on around the clock, not laptops that open and close their lids thirty times a day.\nIn the end, the practical recommendation is the same for both systems: use the native tool. On Linux servers, systemd timers. On Mac, launchd. cron continues to exist on both, continues to work, and continues to be the quickest choice for a throwaway job you need to run in the next two hours. But for any task that will become part of the system\u0026rsquo;s daily routine — like a briefing that needs to arrive on Telegram every morning, rain or shine, laptop open or not — the native scheduler delivers reliability that cron simply cannot match.\nAnd if instead of scheduling by time you want to react to file changes — automatic backup when a SQLite database is modified, image conversion when files appear in a folder — I wrote a post about WatchPaths, the other side of launchd.\n","date":"23/03/2026","lang":"en","tags":["launchd","macos","cron-alternative","launch-agents","launch-daemons","plist-format","systemd"],"title":"Scheduling Tasks on macOS with launchd: No cron, No Workarounds","url":"https://devops.sarmento.org/en/posts/scheduling-tasks-on-macos-with-launchd-no-cron-no-workarounds/"},{"categories":["Linux"],"content":"If you have been managing Debian or Ubuntu servers for any length of time, you probably have a comfort relationship with cron. One line in the crontab, five scheduling fields, and the path to a script — done. cron has worked this way since the 1970s, and that simplicity is exactly what kept it relevant for so long.\nThe problem is that \u0026ldquo;it works\u0026rdquo; and \u0026ldquo;it works well in 2026\u0026rdquo; are different things. When a job fails silently at three in the morning, when you need to figure out which of twenty crontabs scattered across the system contains that one specific task, or when the server reboots and simply misses the execution that should have happened during downtime — those are the moments when cron shows it was designed for an era with different expectations around observability and resilience.\nSystemd timers have been around since 2014 and are part of every systemd-based distribution. Not an extra package, not a third-party tool — infrastructure that is already running on your server right now. This post explains the reasoning behind the switch, the mental model needed to work with timers, and what they concretely offer over cron.\nWhy Touch What Already Works cron in Historical Context cron was born in Unix V7 in 1979. The idea was elegant in its simplicity: a daemon reads a scheduling table, checks every minute whether any entry matches the current time, and if it does, runs the associated command. This model has survived practically unchanged for nearly five decades, which says a lot about the soundness of the original concept.\nThe five-field syntax — minute, hour, day of month, month, and day of week — became a kind of lingua franca among system administrators. Even people who do not work with Linux daily have encountered some variation of it inside CI/CD pipelines, cloud services, and automation tools that adopted the format as a de facto standard for expressing recurring schedules.\nWhere cron Shows Its Age cron does one thing and does it reliably, but the ecosystem around it has changed. On a modern server with dozens of scheduled tasks, certain limitations accumulate and start to weigh.\nThe most immediate one is visibility. cron has no logging of its own — job output goes to the user\u0026rsquo;s local email (if an MTA is configured) or simply disappears. Figuring out whether a job ran, how long it took, and why it failed requires you to implement output redirection, log rotation, and some notification mechanism yourself. It is work that the administrator ends up repeating for each script, in slightly different ways, with no standardization.\nAnother issue is dispersion. Jobs can live in root\u0026rsquo;s crontab, in other users\u0026rsquo; crontabs, in /etc/crontab, in files inside /etc/cron.d/, and in the cron.daily, cron.hourly directories and so on. The crontab -l command shows only the current user\u0026rsquo;s crontab, so building a complete map of everything scheduled on the system means poking around several places. There is no equivalent to systemctl list-timers — a consolidated view with the next execution, the last execution, and the status of each job.\nFinally, cron has no notion of dependencies or state. If the server was powered off when a backup was supposed to run, cron simply skips that execution — there is no native mechanism to recover missed jobs. Likewise, if a job depends on the network being available or another service being active, it is up to the script to check that on its own. These are solvable problems, but the solution always lives outside of cron, in the form of wrappers, locks with flock, manual checks, and extra layers of complexity that pile up over time.\nHow Systemd Timers Work The Two-File Model: service + timer The most important conceptual difference between cron and systemd timers is the separation between \u0026ldquo;what to run\u0026rdquo; and \u0026ldquo;when to run it.\u0026rdquo; In cron, everything lives on the same line — the schedule and the command sit together in the crontab. With systemd timers, those responsibilities are split into two distinct files: a service unit (.service) and a timer unit (.timer).\nThe .service file describes the task itself. It defines which command to run, under which user, with which environment variables, what to do on failure, and which dependencies must be satisfied before execution. It is the same kind of unit file that systemd uses to manage every other service on the system — the difference is that instead of running continuously as a daemon, it uses Type=oneshot to indicate that it runs once and exits.\nThe .timer file handles scheduling exclusively. It defines when the corresponding service should be triggered, with what precision, whether missed executions should be recovered, and how much randomization to apply to the trigger time. By convention, systemd automatically associates a timer with the service of the same name — if the timer is called backup.timer, it will trigger backup.service without any explicit configuration linking the two.\nThis separation feels bureaucratic at first (two files where one line used to suffice), but the practical gain is significant. The service can be tested in isolation at any time with systemctl start backup.service, without waiting for the scheduled time or touching the timer. The timer can be adjusted, disabled, or replaced without touching the execution logic. And both show up in the standard systemd ecosystem — visible in systemctl status, with logs in journalctl, subject to the same resource and security policies as any other unit.\nCalendar Timers (Realtime) vs. Monotonic Timers Systemd timers fall into two categories that reflect two fundamentally different ways of thinking about time.\nCalendar timers, configured with the OnCalendar directive, fire at absolute dates and times. They are the direct equivalent of what cron does: \u0026ldquo;every day at 2 AM,\u0026rdquo; \u0026ldquo;every Monday at 8 AM,\u0026rdquo; \u0026ldquo;on the first day of each month at 3:30 AM.\u0026rdquo; The reference is the system clock, and the timer repeats according to the defined pattern regardless of when the system was started or how much time has passed since the last execution.\nMonotonic timers work with durations relative to some system event. The most common directives are OnBootSec (time after boot), OnStartupSec (time after systemd has started), OnUnitActiveSec (time after the last activation of the associated service), and OnUnitInactiveSec (time after the service last became inactive). A timer configured with OnBootSec=5min and OnUnitActiveSec=1h will run five minutes after boot and then repeat every hour — always measuring from the end of the previous execution, not from a fixed point on the clock.\nThe distinction matters in practice. Calendar timers are the natural choice for tasks that need to happen at specific times — nightly backups, log rotation, daily reports. Monotonic timers make more sense for tasks that depend on intervals between executions — metrics collection, health checks, periodic cache cleanup — especially on machines that are not powered on 24 hours a day, like laptops and desktops.\nThe OnCalendar Format and How to Validate It with systemd-analyze The OnCalendar syntax follows the pattern DayOfWeek Year-Month-Day Hour:Minute:Second, where the day of the week is optional and any field can use * to mean \u0026ldquo;any value.\u0026rdquo; A few examples make the format clearer than any abstract explanation:\n*-*-* 02:00:00 — every day at 2 AM (equivalent to 0 2 * * * in cron) Mon..Fri *-*-* 08:00:00 — weekdays at 8 AM *-*-01 03:30:00 — first day of each month at 3:30 AM *-*-* *:0/15 — every 15 minutes Systemd also accepts human-readable shortcuts like daily, hourly, weekly, and monthly, which are internally translated into full expressions in the format above.\nOne concrete advantage over cron syntax is the ability to stack multiple OnCalendar directives in the same timer. A schedule that runs at different times on weekdays and weekends — something that in cron would require two separate entries — can live in a single .timer file with two OnCalendar lines.\nBefore activating a timer, the systemd-analyze calendar command lets you validate and visualize the expression with no risk. Running systemd-analyze calendar \u0026quot;Mon..Fri *-*-* 08:00:00\u0026quot; makes systemd show the normalized form of the expression and calculate the next trigger dates. It is the kind of tool cron never had — a way to test the schedule before putting it in production, instead of waiting to see if the job fires at the right time.\nWhat Timers Do That cron Does Not Integrated Logging with journald When a cron job fails, the investigation usually starts with an uncomfortable question: \u0026ldquo;where did the output of this script go?\u0026rdquo; Depending on how the job was written, the answer might be a log file in some corner of the filesystem, the local mail spool, or simply nowhere. Every administrator solves this their own way — redirecting stderr to a file, configuring an MTA to deliver local emails, adding logger calls inside scripts — and the result is a patchwork where each job has its own archaeology of logs.\nWith systemd timers, all standard and error output from the service goes automatically to the journal. No configuration, no redirection, no MTA dependency. The command journalctl -u backup.service shows the complete execution history for that service with precise timestamps, and filters like --since yesterday or --priority err let you slice exactly what matters. The timer\u0026rsquo;s output and the service\u0026rsquo;s output live in the same logging system that the rest of systemd uses, so correlating events across different system components stops being an exercise in grepping through multiple files.\nIn practice, this means the question shifts from \u0026ldquo;where is the log?\u0026rdquo; to \u0026ldquo;what does the log say?\u0026rdquo; — which is the question that actually matters when something goes wrong at three in the morning.\nPersistent=true and Missed Executions cron operates on a simple premise: if the system is on at the moment a job is supposed to run, it runs. If it is not, the execution is lost without warning and without recovery. For tasks on servers that stay up around the clock, this is rarely a problem. But for any scenario involving planned reboots, maintenance windows, kernel updates that require a restart, or machines that occasionally get shut down, this is a real gap.\nThe Persistent=true directive in the .timer file addresses this directly. When enabled, systemd writes the timestamp of the last successful timer execution to disk. On the next boot, it compares that timestamp with the schedule and, if it detects that one or more executions were missed while the system was down, triggers the service as soon as possible. The behavior is deterministic and visible — systemctl list-timers shows both the last and next execution, so confirming that recovery happened is trivial.\nReplicating this behavior with cron would require maintaining external control files, checking timestamps at the beginning of each script, and programmatically deciding whether to proceed — logic that every administrator would have to implement, test, and maintain on their own.\nDependencies Between Units A common pattern in cron jobs is starting the script with manual checks: testing whether the network is reachable, verifying that a database is responding, checking whether a remote filesystem is mounted. These guards exist because cron has no awareness of system state — it fires the command at the scheduled time and whatever happens next is the script\u0026rsquo;s problem.\nSystemd timers inherit the dependency system of systemd itself. The After=, Requires=, and Wants= directives in the .service file let you declare that the task should only run after certain units are active. A backup that depends on an NFS share can declare After=mnt-backup.mount and Requires=mnt-backup.mount — if the mount point is not available, systemd does not even attempt to run the service, and the reason is recorded in the journal. A job that needs connectivity can use After=network-online.target and Wants=network-online.target instead of looping while trying to resolve DNS — this pattern shows up in practice in the reverse SSH tunnel service I use to reach machines behind NAT.\nThis does not eliminate every need for checks inside scripts — application dependencies, API states, and business conditions remain the job\u0026rsquo;s responsibility. But the infrastructure layer (network, mounts, system services) can be handled declaratively, in the same place where the rest of the service configuration lives.\nResource Control and Sandboxing Every cron job runs essentially without limits. If a script consumes all available memory, saturates the CPU, or writes until the disk is full, the impact falls on the entire system. Tools like nice, ionice, and ulimit exist, but they need to be invoked explicitly inside each job, and the granularity of control is limited.\nBecause services triggered by timers are regular systemd units, they have access to the same set of resource controls available to any other service. Directives like CPUQuota=50%, MemoryMax=512M, and IOWeight=100 can be declared directly in the .service file, imposing limits via cgroups without any modification to the script being executed. A backup job that should not consume more than half the CPU and half a gigabyte of RAM expresses that in two lines of configuration, and systemd enforces it.\nBeyond resources, systemd offers security directives that restrict what the service can do on the system. ProtectHome=true prevents access to user home directories, ProtectSystem=strict makes the root filesystem read-only (except for paths explicitly allowed with ReadWritePaths=), NoNewPrivileges=true blocks privilege escalation, and PrivateTmp=true gives the service an isolated /tmp. None of these protections exist in the cron model, where every job runs with the full permissions of the user that scheduled it, with no additional isolation.\nFrom Theory to Practice: Anatomy of a Timer Minimal .service Structure A .service file for use with timers needs very little. The example below shows a unit that runs a script to clean up old logs:\n[Unit] Description=Clean up logs older than 30 days [Service] Type=oneshot ExecStart=/usr/local/bin/clean-logs.sh The [Unit] section contains only the description, which appears in the output of systemctl list-timers and systemctl status. The [Service] section sets the type to oneshot — indicating that the process runs, exits, and that counts as success — and the absolute path to the command in ExecStart.\nThere is no [Install] section because the service is not enabled directly. The timer is what gets enabled; the service exists only to be triggered by it.\nA few optional directives worth knowing from the start:\n[Service] Type=oneshot User=backup Group=backup ExecStart=/usr/local/bin/clean-logs.sh TimeoutStartSec=5min User and Group define which account the process runs under — without them, the default is root. TimeoutStartSec sets a maximum execution time, after which systemd kills the process and marks the service as failed. For scripts that can hang waiting on a network resource or a lock, this limit prevents the task from staying stuck indefinitely.\nThe file should be saved in /etc/systemd/system/ with the .service extension. Following the example: /etc/systemd/system/clean-logs.service.\nMinimal .timer Structure The corresponding timer must share the same base name as the service. For clean-logs.service, the file will be clean-logs.timer:\n[Unit] Description=Run log cleanup daily at 3 AM [Timer] OnCalendar=*-*-* 03:00:00 Persistent=true [Install] WantedBy=timers.target The [Timer] section is where the scheduling logic lives. OnCalendar defines the trigger time — in this case, every day at 3 AM. Persistent=true ensures that if the system is off at 3 AM, the execution happens as soon as possible after boot.\nThe [Install] section with WantedBy=timers.target is what allows the timer to be enabled with systemctl enable. When enabled, systemd includes this timer in the timers.target, which is automatically activated during boot. Unlike the .service, the [Install] section is mandatory here — without it, the timer works if started manually but does not survive a reboot.\nFor a monotonic timer, the [Timer] section would look different:\n[Timer] OnBootSec=2min OnUnitActiveSec=1h This timer fires two minutes after boot and then repeats every hour, measuring from the end of the previous execution. Note that Persistent=true does not apply to monotonic timers — the directive only makes sense with OnCalendar, where there is an absolute time to compare against.\nThe file goes in the same directory: /etc/systemd/system/clean-logs.timer.\nActivating, Verifying, and Monitoring With both files in place, the activation sequence is always the same. First, systemd needs to reload its definitions to see the new units:\nsudo systemctl daemon-reload Then, enable and start the timer:\nsudo systemctl enable clean-logs.timer sudo systemctl start clean-logs.timer The enable creates the symbolic link so the timer activates on every boot. The start puts it into operation immediately without waiting for the next reboot. Both operations can be combined with the --now flag:\nsudo systemctl enable --now clean-logs.timer To confirm the timer is active and check when the next execution will happen:\nsystemctl list-timers | grep clean-logs The output shows the next scheduled execution, how much time remains, when the last execution happened, and the name of the associated unit — all on one line. For a more detailed view:\nsystemctl status clean-logs.timer Before waiting for the scheduled time, it makes sense to test the service in isolation to make sure it works:\nsudo systemctl start clean-logs.service And then check the output in the journal:\njournalctl -u clean-logs.service -n 20 If the service completed without errors, the timer can handle the scheduling from here. If something failed, the journal will show exactly what happened — no hunting for log files across the filesystem, no depending on local email, no guessing whether the script even ran.\nReading Logs with journalctl The systemd journal is the automatic destination for everything services write to stdout and stderr. It requires no configuration, does not depend on output redirection in the script, and does not need a working MTA. Knowing how to navigate it is what turns the theoretical advantage of integrated logging into a practical daily gain.\nLogs for a Specific Service The most common filter is by unit. To see all entries for the log cleanup service used in earlier examples:\njournalctl -u clean-logs.service The output includes timestamps, the process PID, and everything the script sent to standard output and error. Entries appear in chronological order, oldest to newest.\nTo see only the most recent executions without scrolling through the entire history, the -n flag limits the number of lines:\njournalctl -u clean-logs.service -n 30 And to follow output in real time while the service runs — useful when testing with systemctl start — the -f flag works like tail -f:\njournalctl -u clean-logs.service -f Filtering by Time On a server with weeks or months of history, filtering by time range is more practical than scrolling through pages of log. journalctl accepts time filters in fairly straightforward language:\njournalctl -u clean-logs.service --since today journalctl -u clean-logs.service --since yesterday --until today journalctl -u clean-logs.service --since \u0026#34;2026-03-20 03:00:00\u0026#34; --until \u0026#34;2026-03-20 03:05:00\u0026#34; The last example is particularly useful for inspecting a specific execution — knowing the OnCalendar time, you just open a window of a few minutes around it to see exactly what happened.\nIdentifying Failures When a service exits with a non-zero exit code, systemd marks it as failed. The systemctl status command shows this information in summary form:\nsystemctl status clean-logs.service The output includes the current state (inactive (dead) for a oneshot that finished successfully, failed if there was an error), the exit code, and the last few journal lines. To filter only error messages and above:\njournalctl -u clean-logs.service -p err Priority levels follow the syslog standard: emerg, alert, crit, err, warning, notice, info, and debug. The -p err filter shows everything from err upward, which is usually enough to get straight to the problem.\nCorrelating Timer and Service Sometimes the problem is not in the service itself but in how the timer fires — it may not be activating at the expected time, or it may be activating twice. To see entries for both the timer and the service together, pass both units to journalctl:\njournalctl -u clean-logs.timer -u clean-logs.service --since today This produces an interleaved timeline where you can see the exact moment the timer fired and what the service did next. It is the kind of visibility that with cron would require cross-referencing syslog entries with the script\u0026rsquo;s redirected output — when that output exists.\nA Note on Log Persistence By default on Debian and Ubuntu, the journal stores logs in /var/log/journal/ persistently across reboots. Disk space is managed automatically by journald, which by default limits the journal to 10% of the filesystem or 4 GB (whichever is smaller). To check how much space the journal is consuming:\njournalctl --disk-usage If the /var/log/journal/ directory does not exist on your system, the journal is running in volatile mode — storing only in memory, with total loss on reboot. Creating the directory and restarting the service fixes this:\nsudo mkdir -p /var/log/journal sudo systemctl restart systemd-journald For most servers the default configuration is sufficient, but it is worth confirming that persistence is active before relying on the journal as a historical record of your timer executions.\nQuick Reference: cron → OnCalendar The table below maps the most common cron schedules to their OnCalendar equivalents. In every case, the expression can be validated before use with systemd-analyze calendar \u0026quot;expression\u0026quot;.\nDescription cron OnCalendar Every minute * * * * * *-*-* *:*:00 Every 5 minutes */5 * * * * *-*-* *:0/5:00 Every hour (minute zero) 0 * * * * hourly Every day at midnight 0 0 * * * daily Every day at 2:30 AM 30 2 * * * *-*-* 02:30:00 Every Monday at 8 AM 0 8 * * 1 Mon *-*-* 08:00:00 Weekdays at 6 PM 0 18 * * 1-5 Mon..Fri *-*-* 18:00:00 First day of month at 3 AM 0 3 1 * * *-*-01 03:00:00 Every Sunday at midnight 0 0 * * 0 Sun *-*-* 00:00:00 Every 6 hours 0 */6 * * * 0/6:00:00 Once a week (Sunday, midnight) 0 0 * * 0 weekly Once a month (1st, midnight) 0 0 1 * * monthly The shortcuts hourly, daily, weekly, and monthly are abbreviated forms that systemd expands internally into the corresponding full expression. They work well for simple schedules, but for any variation — a time other than midnight, a specific day of the week — the explicit expression is needed.\nA subtle difference worth noting: in cron, the day-of-week field uses 0 or 7 for Sunday and numbers 1 through 6 for the other days. In OnCalendar, days are always three-letter English abbreviations (Mon, Tue, Wed, Thu, Fri, Sat, Sun), and ranges use .. instead of -. The notation is more readable, but it requires an adjustment for those who have cron syntax in their muscle memory.\nWhen cron Still Makes Sense It would be dishonest to close this post without acknowledging that cron did not become a bad tool overnight. It remains a perfectly valid option in specific contexts, and migrating everything to systemd timers on principle, without a concrete gain, is trading useful work for busywork.\nOn systems where the schedule comes down to half a dozen simple tasks — a backup script, log rotation, a temp file cleanup — cron solves in one line what timers solve with two files. If those tasks do not need centralized logging, do not depend on other services, and run on a server that stays up around the clock, the cost-benefit of migrating is questionable. cron works, the team knows it, and the risk of changing things is greater than the gain.\ncron also has the advantage of portability. It exists on virtually any Unix-like system — BSDs, Linux distributions without systemd like Alpine and Void, minimal containerized environments where systemd is not present. If you maintain scripts that need to run across heterogeneous environments, the crontab is the most reliable common denominator. Systemd timers are simply not an option where systemd does not exist.\nAnother case is users without administrative privileges. Any user can edit their own crontab with crontab -e without needing sudo. Timers in /etc/systemd/system/ require root permissions. User timers do exist (in ~/.config/systemd/user/), but they only run while the user has an active session — unless loginctl enable-linger is enabled for that account, which itself requires administrator intervention.\nThe point is not to pick a side. On the same server, stable legacy cron jobs and systemd timers can coexist without any conflict. The practical recommendation is to adopt timers for new tasks and migrate existing ones as the opportunity comes up — when a job needs better logging, when a silent failure causes a real problem, when a script gains a dependency that justifies explicit declaration. Gradual migration driven by concrete need tends to produce better results than a mass conversion done all at once.\nIf you want a practical case to start with, the cleanup of old Snap revisions is a good candidate for your first timer. And if your daily routine includes a Mac, I wrote a post about launchd — the native macOS equivalent.\n","date":"23/03/2026","lang":"en","tags":["cron-alternative","timer-management","linux","systemd"],"title":"Systemd Timers: Time to Retire cron","url":"https://devops.sarmento.org/en/posts/systemd-timers-time-to-retire-cron/"},{"categories":["Static Sites"],"content":"A static site has no backend. No database, no application server processing requests — and that\u0026rsquo;s exactly what makes it fast, cheap, and resilient. But that simplicity comes at a cost when you need anything that depends on persistent state, and comments are the most obvious case. In WordPress or Ghost, the commenting system is part of the application. In a site generated by Hugo, Jekyll, or Eleventy, that layer simply doesn\u0026rsquo;t exist.\nThe solution that dominated for over a decade was Disqus: a JavaScript snippet, an iframe with a comment box, and you\u0026rsquo;re done. The real cost of that convenience hides in the fine print — tracking scripts, third-party cookies, ads injected into your site, and all data stored on servers you don\u0026rsquo;t control. There are alternatives based on GitHub Issues (Utterances, Giscus), which work well for technical blogs but require readers to have a GitHub account. And there\u0026rsquo;s a category that solves the problem in the most direct way: a lightweight comment server that you host yourself, with your own data, on your own domain. Isso fits that category.\nWhat is Isso Isso is an open-source comment server written in Python, created in 2012 as a self-hosted alternative to Disqus. The name comes from the German Ich schrei sonst — roughly \u0026ldquo;or else I scream\u0026rdquo;. The architecture is deliberately simple: the server is a Python application that exposes a REST API and stores everything in a single SQLite file. The client is a JavaScript file of roughly 20 KB (gzipped) that you embed in your pages. There\u0026rsquo;s no elaborate admin panel, no account system, no social media integration. Visitors write comments providing a name and email — optionally anonymous — and can edit or delete what they wrote within a configurable time window.\nComments support Markdown, threading works natively with nested replies, and moderation can be enabled so that comments only appear after approval. Isso also includes an import tool that reads XML dumps from Disqus and WordPress.\nThe choice of SQLite as a backend is a design decision, not a limitation. Blog comments are small data, with sporadic writes and a massive read-to-write ratio. SQLite handles that profile effortlessly, and backing up your entire comment history comes down to copying a single file.\nArchitecture The setup we\u0026rsquo;re going to build works like this: your blogs stay hosted on Cloudflare Pages (or any other static hosting service), and Isso runs on a separate VPS, accessible via a subdomain like isso.yourdomain.org. When a visitor opens a post, the Isso JavaScript loads from the VPS, fetches the comments for that page, and renders the comment section. If the VPS goes down for any reason, the blog keeps working normally — the visitor just won\u0026rsquo;t see comments until the service comes back. The failure is graceful.\nIsso listens on a local port and doesn\u0026rsquo;t implement TLS. A reverse proxy — Nginx, Caddy, or whatever you already have — handles HTTPS. Without HTTPS on the Isso endpoint, comments won\u0026rsquo;t work on any site served via HTTPS, because the browser silently refuses mixed-content connections.\nPreparing the server This tutorial uses a VPS running Ubuntu 24.04 with Nginx managed by WordOps. If you have a different Nginx setup, adapt the reverse proxy section. Everything else is identical.\nInstall the dependencies:\napt update apt install python3-dev python3-venv build-essential Create the directory structure. In this example, everything lives under /var/www/isso.yourdomain.org — configuration, database, and virtualenv in the same place, which simplifies backups:\nmkdir -p /var/www/isso.yourdomain.org/{config,db} Create the virtualenv and install Isso and gunicorn:\npython3 -m venv /var/www/isso.yourdomain.org/venv /var/www/isso.yourdomain.org/venv/bin/pip install isso gunicorn Set permissions for the web server user (on WordOps and many Nginx setups, that\u0026rsquo;s www-data):\nchown -R www-data:www-data /var/www/isso.yourdomain.org Configuration Isso uses INI-format configuration files. If you\u0026rsquo;re serving comments for a single site, one file is enough. To serve multiple sites from the same instance — which is our case, with two blogs — the approach changes: each site needs its own configuration file with a name field that identifies it, and Isso runs via gunicorn with the isso.dispatch module instead of the isso run command directly.\nThe official documentation is explicit on this point: the host parameter in a single config accepts multiple URLs, but only variants of the same site (for example, with and without www, or HTTP and HTTPS). Different sites require separate configs.\nCreate the file for the first site, /var/www/isso.yourdomain.org/config/blog1.cfg:\n[general] name = blog1 dbpath = /var/www/isso.yourdomain.org/db/blog1.db host = https://blog1.yourdomain.org max-age = 15m notify = stdout [moderation] enabled = true [server] listen = http://127.0.0.1:8000 [guard] enabled = true ratelimit = 2 direct-reply = 3 reply-to-self = false require-author = true require-email = true [admin] enabled = true password = CHANGE_TO_A_STRONG_PASSWORD And the file for the second site, /var/www/isso.yourdomain.org/config/blog2.cfg:\n[general] name = blog2 dbpath = /var/www/isso.yourdomain.org/db/blog2.db host = https://blog2.yourdomain.org max-age = 15m notify = stdout [moderation] enabled = true [server] listen = http://127.0.0.1:8000 [guard] enabled = true ratelimit = 2 direct-reply = 3 reply-to-self = false require-author = true require-email = true [admin] enabled = true password = CHANGE_TO_A_STRONG_PASSWORD The key points:\nname — unique identifier for the site. This value defines the subpath in the API URL (/blog1/, /blog2/). dbpath — absolute path. Each site gets its own SQLite database. host — the URL of your blog, not of Isso. Used for CORS validation. moderation enabled = true — every comment goes through approval before appearing. Disable it once you\u0026rsquo;re comfortable with the guard settings and your readership. require-author and require-email = true — significantly reduces anonymous spam. admin enabled = true — enables the moderation panel, accessible at https://isso.yourdomain.org/blog1/admin. If you\u0026rsquo;re serving a single site, you can use one file and run with isso -c /path/to/config.cfg run directly. In that case, the name field isn\u0026rsquo;t needed and the API endpoints live at the root (no subpath).\nRunning with gunicorn and systemd For multi-site setups, Isso uses the isso.dispatch module with gunicorn. Gunicorn needs to know which configuration files to load through the ISSO_SETTINGS environment variable, with paths separated by semicolons.\nTest manually before creating the service:\nsudo -u www-data \\ ISSO_SETTINGS=\u0026#34;/var/www/isso.yourdomain.org/config/blog1.cfg;/var/www/isso.yourdomain.org/config/blog2.cfg\u0026#34; \\ /var/www/isso.yourdomain.org/venv/bin/gunicorn isso.dispatch -b 127.0.0.1:8000 You should see in the logs that gunicorn started and both sites were connected. In another terminal, confirm:\ncurl http://127.0.0.1:8000/blog1/info curl http://127.0.0.1:8000/blog2/info Each should return a JSON with the Isso version, the configured origin, and the moderation status. If both respond, Ctrl+C the gunicorn process and create the systemd service.\nCreate /etc/systemd/system/isso.service:\n[Unit] Description=Isso Commenting Server After=network.target [Service] Type=simple User=www-data Group=www-data Environment=ISSO_SETTINGS=/var/www/isso.yourdomain.org/config/blog1.cfg;/var/www/isso.yourdomain.org/config/blog2.cfg ExecStart=/var/www/isso.yourdomain.org/venv/bin/gunicorn isso.dispatch -b 127.0.0.1:8000 WorkingDirectory=/var/www/isso.yourdomain.org Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target Enable and start:\nsystemctl daemon-reload systemctl enable --now isso systemctl status isso From this point on, Isso starts automatically with the server.\nReverse proxy with Nginx Isso needs to be accessible via HTTPS so that visitors\u0026rsquo; browsers can communicate with the API. The process listens on 127.0.0.1:8000 and Nginx handles the proxy.\nIf you use WordOps, the command is straightforward:\nwo site create isso.yourdomain.org --proxy=127.0.0.1:8000 -le This creates the vhost, configures the reverse proxy, and provisions the Let\u0026rsquo;s Encrypt certificate automatically.\nIf you don\u0026rsquo;t use WordOps, create the vhost manually. A minimal example for /etc/nginx/sites-available/isso.yourdomain.org:\nserver { listen 80; listen [::]:80; server_name isso.yourdomain.org; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } Enable the site and obtain the SSL certificate:\nln -s /etc/nginx/sites-available/isso.yourdomain.org /etc/nginx/sites-enabled/ nginx -t \u0026amp;\u0026amp; systemctl reload nginx certbot --nginx -d isso.yourdomain.org Test from outside the server:\ncurl https://isso.yourdomain.org/blog1/info curl https://isso.yourdomain.org/blog2/info If they return JSONs with the version and origin of each site, the server is ready.\nWhy not Docker The official Isso image (ghcr.io/isso-comments/isso:release) ships with gunicorn hardcoded to port 8080 in the entrypoint. The listen directive in the configuration file and the GUNICORN_CMD_ARGS environment variable are both ignored — gunicorn always tries to bind to port 8080, regardless of what you configure.\nWith network_mode: host, if port 8080 is already in use on the host by another service (a control panel, for instance), the container enters an error loop and never starts. With bridge networking and port mapping (-p 127.0.0.1:OTHER_PORT:8080), the Docker proxy should translate ports, but in some environments the NAT doesn\u0026rsquo;t work correctly — the connection establishes and is immediately reset, even though Isso responds normally from inside the container.\nThe native installation with virtualenv, gunicorn, and systemd eliminates all those intermediate layers. You control the port directly, without depending on the image\u0026rsquo;s entrypoint, the Docker proxy, or Docker\u0026rsquo;s iptables/nftables rules. For a lightweight Python application with a SQLite database, Docker\u0026rsquo;s complexity doesn\u0026rsquo;t pay for itself.\nHugo integration With the server running and accessible via HTTPS, the Hugo side comes down to two things: including the Isso script in the post template and — if you use a CMS like Pages CMS — adding the corresponding field to the CMS configuration so you can enable or disable comments per post.\nThe comments partial Create the file layouts/partials/comments.html in your blog\u0026rsquo;s repository:\n\u0026lt;section id=\u0026#34;isso-comments\u0026#34;\u0026gt; \u0026lt;script data-isso=\u0026#34;https://isso.yourdomain.org/blog1/\u0026#34; data-isso-css=\u0026#34;true\u0026#34; data-isso-lang=\u0026#34;en\u0026#34; data-isso-reply-to-self=\u0026#34;false\u0026#34; data-isso-require-author=\u0026#34;true\u0026#34; data-isso-require-email=\u0026#34;true\u0026#34; data-isso-avatar=\u0026#34;true\u0026#34; data-isso-avatar-bg=\u0026#34;#f0f0f0\u0026#34; data-isso-vote=\u0026#34;true\u0026#34; src=\u0026#34;https://isso.yourdomain.org/blog1/js/embed.min.js\u0026#34; \u0026gt;\u0026lt;/script\u0026gt; \u0026lt;noscript\u0026gt;Please enable JavaScript to view comments.\u0026lt;/noscript\u0026gt; \u0026lt;section id=\u0026#34;isso-thread\u0026#34;\u0026gt;\u0026lt;/section\u0026gt; \u0026lt;/section\u0026gt; The data-isso value is the base URL of your Isso instance, including the site\u0026rsquo;s subpath (/blog1/). The src points to the script served by the Isso instance itself. The data-isso-* attributes control client behavior — the full reference is in the official documentation.\nIncluding in the template How you include the partial depends on your theme. Some Hugo themes have native Isso support and expose a front matter field to enable it — check your theme\u0026rsquo;s documentation to find out whether that\u0026rsquo;s the case and what the expected field name is (it could be comments, isso, disableComments, among others). In those cases, just configure the parameters in hugo.toml and the theme handles the rest.\nIf the theme doesn\u0026rsquo;t have native support, edit the post template (layouts/_default/single.html or the equivalent in your theme) and add the partial call where comments should appear — usually after the post content and before the footer:\n{{ if ne .Params.comments false }} {{ partial \u0026#34;comments.html\u0026#34; . }} {{ end }} With this logic, comments appear on all posts by default. To disable them on a specific post, add comments: false to the front matter.\nPages CMS configuration If you manage content through Pages CMS, add the comments field to .pages.yml so it shows up in the editing interface. The field name must match what the template expects — in the example above, it\u0026rsquo;s comments:\n- name: comments label: Comments type: boolean default: true This lets you enable or disable comments per post directly from the CMS interface, without editing Markdown manually.\nModeration and notifications With moderation = true in the configuration, every new comment enters a queue and stays invisible until you approve it. Approval can be done through the admin panel (accessible at https://isso.yourdomain.org/blog1/admin) or via signed links sent by email.\nTo receive email notifications, change notify = stdout to notify = smtp and configure the [smtp] section:\n[general] notify = smtp [smtp] username = your@email.com password = your-password-or-app-password host = smtp.yourprovider.com port = 587 security = starttls to = your@email.com from = isso@yourdomain.org timeout = 10 One thing worth noting: Isso sends emails via SMTP from the server where it\u0026rsquo;s running. If the sender domain doesn\u0026rsquo;t have SPF, DKIM, and rDNS records properly configured, those emails are likely to land in spam. If you don\u0026rsquo;t want to deal with IP reputation and email configuration, point the SMTP to a relay service like Mailgun, Brevo, or Gmail with an app password.\nBackup The database is a single SQLite file per site. If that file gets corrupted or deleted, you lose your entire comment history. The solution is a cron job that copies the files periodically:\nsqlite3 /var/www/isso.yourdomain.org/db/blog1.db \u0026#34;.backup /backups/isso-blog1-$(date +%Y%m%d).db\u0026#34; sqlite3 /var/www/isso.yourdomain.org/db/blog2.db \u0026#34;.backup /backups/isso-blog2-$(date +%Y%m%d).db\u0026#34; SQLite\u0026rsquo;s .backup command copies while the database is live, without inconsistencies even if Isso is writing at that moment. The file rarely exceeds a few megabytes, so copying to external storage with rsync or rclone is trivial.\nIf you organized everything under /var/www/isso.yourdomain.org/ as we did here, and your server already has a backup script that sweeps /var/www/, the databases are already covered without any additional configuration.\nMigrating from Disqus or WordPress If you already have comments in another system, Isso includes an import tool:\n/var/www/isso.yourdomain.org/venv/bin/isso -c /var/www/isso.yourdomain.org/config/blog1.cfg import -t disqus disqus-export.xml /var/www/isso.yourdomain.org/venv/bin/isso -c /var/www/isso.yourdomain.org/config/blog1.cfg import -t wordpress wordpress-export.xml Comments are associated with the original page URIs. If the URL structure changed when migrating to Hugo, imported comments will point to the old paths. The fix is straightforward in SQLite:\nsqlite3 /var/www/isso.yourdomain.org/db/blog1.db \\ \u0026#34;UPDATE threads SET uri = \u0026#39;/new/path/\u0026#39; WHERE uri = \u0026#39;/old/path/\u0026#39;;\u0026#34; Conclusion Isso does exactly what it promises — self-hosted, lightweight comments with no tracking — without trying to do more than it should. The native installation with virtualenv and gunicorn is simple, predictable, and uses minimal resources. The database is a file you can open with sqlite3, comments are plain text with Markdown, and if the project is ever abandoned, your data remains readable without any special tooling.\nFor anyone who already runs a server and wants full control over their visitors\u0026rsquo; data, it\u0026rsquo;s the choice that makes the most sense.\n","date":"21/03/2026","lang":"en","tags":["self-hosting","hugo","static-site","comments","tracking-free","linux","isso"],"title":"Comments on static sites with Isso — lightweight, self-hosted, and tracking-free","url":"https://devops.sarmento.org/en/posts/comments-on-static-sites-with-isso-lightweight-self-hosted-and-tracking-free/"},{"categories":["Static Sites"],"content":"Why leave WordPress The weight of running a dynamic CMS WordPress is an extraordinary piece of software that powers nearly half the internet. That said, keeping a WordPress installation healthy is a job that never ends. Every visit to your site triggers a chain of events: the server receives the request, PHP wakes up, queries MySQL, assembles the page on the fly, and sends the HTML back to the browser. Multiply that by a hundred simultaneous visitors and you have a server sweating to deliver pages that, on most blogs, are exactly the same for everyone.\nAnd then there\u0026rsquo;s everything else. Plugins need constant updates — not because developers enjoy shipping new versions, but because every plugin is a potential entry point for anyone looking to break in. WordPress itself requires regular updates for the same reason. The database grows, accumulating post revisions, expired transients, and metadata from plugins you uninstalled two years ago. The server needs the right PHP version, the right extensions enabled, and enough memory to stay upright when Google decides to crawl everything at once. Anyone who runs WordPress knows the question isn\u0026rsquo;t whether something will break — it\u0026rsquo;s when.\nAdd to that the appetite of content scrapers, both the ones feeding AI training pipelines and the ones used by competitors (or by yourself) to analyze content and SEO.\nNone of this is WordPress\u0026rsquo;s fault. It was built to be flexible, and flexibility has a cost. The problem shows up when all you want is to publish text and images — and the system that\u0026rsquo;s supposed to help you do that demands more maintenance than the content itself.\nWhat a static site solves (and what it doesn\u0026rsquo;t) A static site is the opposite of that machinery. Instead of assembling each page at the moment of the visit, every page is generated ahead of time — plain HTML files sitting on the server, ready to be served. No PHP running, no database, no admin login exposed to the internet. The server only needs to do one thing: deliver files. Any server does that fast, and the result is a site that loads in milliseconds with no attack surface to exploit.\nSecurity improves dramatically. No database means no SQL injection. No web-accessible admin panel means no brute force on /wp-login.php. No PHP means no remote code execution. The site is a collection of text files — about as vulnerable as a folder of documents on a file server.\nBut it\u0026rsquo;s worth being honest about the limits. A static site isn\u0026rsquo;t a drop-in WordPress replacement for everyone. Features that depend on server-side processing — native comments (though self-hosted solutions exist), database-driven search, e-commerce with shopping carts, members-only areas with login — don\u0026rsquo;t exist in plain HTML. They can be added with external services (and we\u0026rsquo;ll cover that later), but complexity goes up. If your site relies heavily on real-time interaction or dynamic features, WordPress still makes sense. For blogs, news sites, portfolios, documentation, and institutional pages, a static generator delivers more with less headache.\nThe pieces of the puzzle For a static site to work like a real blog — with a visual editor, automatic deploys, and no dependency on your local machine — you need four pieces working together. None of them is complicated on its own, but understanding what each one does before you start saves confusion later.\ngraph TD A[\"Pages CMSAuthor writes in browser\"] --\u003e|commit via API| C[\"GitHubMarkdown + theme + config + images\"] B[\"Developergit push (theme, config)\"] --\u003e|git push| C C --\u003e|webhook| D[\"Cloudflarehugo build → global CDN\"] D --\u003e|static HTML| E[\"yoursite.com\"] Hugo — the site generator Hugo is the program that turns your text files into a complete HTML website. You write in Markdown (a plain text format with lightweight markup for headings, links, bold, and the like), pick a visual theme, and Hugo compiles everything into ready-to-publish HTML pages. It generates the entire site at once — every post, every category page, the RSS feed, the sitemap — and places the output in a folder called public/.\nHugo is written in Go and is absurdly fast. A site with 500 posts compiles in under a second. That matters because every time someone publishes a new post, the entire site needs to be rebuilt, and nobody wants to wait minutes for that.\nOther static generators exist — Jekyll, Eleventy, Astro — but Hugo has the right combination of speed, maturity, and theme ecosystem for what we\u0026rsquo;re building here.\nPages CMS — the editor for humans A static generator on its own requires you to edit text files in a Git repository. For a developer, that\u0026rsquo;s natural. For anyone else, it\u0026rsquo;s a nightmare. Pages CMS solves this by placing a friendly web interface in front of the repository.\nIn practice, the author opens Pages CMS in the browser, sees a list of posts, clicks \u0026ldquo;new post,\u0026rdquo; fills in the fields (title, date, categories, content), and saves. Behind the scenes, Pages CMS takes all of that and creates a Markdown file in the GitHub repository with the front matter (the post\u0026rsquo;s metadata) and the formatted body text. The author doesn\u0026rsquo;t need to know that Git exists.\nPages CMS is open source, free, and runs on their own service — you don\u0026rsquo;t install anything on your server. The entire configuration is a single YAML file in the repository that defines which fields each content type has.\nCloudflare — the invisible hosting Cloudflare comes in as the infrastructure that builds and hosts the site. When someone publishes a post through Pages CMS (or when you push directly to the repository), Cloudflare detects the change, clones the repository, runs Hugo to generate the HTML, and distributes the result across its global network of over 300 data centers. A visitor in São Paulo gets the site from a server in São Paulo; a visitor in Lisbon gets it from a server in Lisbon.\nCloudflare\u0026rsquo;s free plan offers unlimited bandwidth and 500 builds per month — enough to publish more than 15 posts a day without paying a cent. The site is fast by default, HTTPS is automatic, and if an article goes viral, Cloudflare absorbs the traffic without flinching. (For a detailed look at the real limits of each service in this stack and what to do when you hit them, see The Dark Side of Free.)\nGitHub — the vault where everything lives GitHub is the central repository. All of the site\u0026rsquo;s content — the Markdown files, the images, the Hugo configuration, the theme files — lives in a Git repository. That means every change is versioned: if someone edits a post and the result looks wrong, you just roll back to the previous version. If someone deletes something by accident, the full history is preserved.\nGitHub is also the connection point between the other pieces. Pages CMS communicates with GitHub to read and write content. Cloudflare connects to GitHub to detect changes and trigger the build. Your local machine, if you prefer editing locally, also talks to GitHub via Git. Everything converges to the same place, and everything is recorded.\nHow the pieces fit together Four independent tools are useless if they don\u0026rsquo;t talk to each other. The good news is that the integration between them is nearly automatic — once configured, the flow works without human intervention.\nThe flow: from editor to published site Everything starts in Pages CMS. The author opens the browser, accesses the dashboard, and writes a post. Could be on the office desktop, the laptop on the couch, or a phone on the bus — doesn\u0026rsquo;t matter, it\u0026rsquo;s a web page. They fill in the title, pick a category, write the text, upload an image, and click save.\nPages CMS takes that data and makes a commit to the GitHub repository. In practice, it creates a Markdown file with the post\u0026rsquo;s metadata in the header (title, date, categories) and the content right below. If the author uploaded an image, it goes along to the repository\u0026rsquo;s media folder. All of this happens through GitHub\u0026rsquo;s API — Pages CMS never stores anything, it only reads from and writes to the repository.\nGitHub receives the commit and fires a webhook to Cloudflare. Cloudflare wakes up, clones the latest version of the repository, and runs Hugo. Hugo reads all the Markdown files, applies the visual theme, and generates the entire site — each post becomes an HTML page, the listings are recreated, the RSS feed is updated, the sitemap is rebuilt. The result is a folder with dozens (or hundreds) of static HTML files, ready to serve.\nCloudflare distributes those files across its network of data centers spread around the world. When someone visits the site, they receive the HTML from the nearest server. No queue, no processing, no database — just a file being delivered.\nFrom the click on \u0026ldquo;save\u0026rdquo; to the post being live, the entire process takes between one and two minutes. Most of that time is Cloudflare provisioning the build environment. Hugo itself compiles the site in under a second.\nWhat happens when someone clicks \u0026ldquo;Save\u0026rdquo; Worth detailing the sequence because it shows where each piece acts and where things can go wrong:\nThe author clicks \u0026ldquo;Save\u0026rdquo; in Pages CMS. The CMS sends a request to GitHub\u0026rsquo;s API with the Markdown file contents and, if there\u0026rsquo;s an image, the image binary. GitHub receives it and records it as a normal commit — with author, date, and message. That commit shows up in the repository\u0026rsquo;s history like any other, and can be reverted if needed.\nGitHub notifies Cloudflare that a change was made to the main branch. Cloudflare starts the build process: it provisions a temporary container, clones the repository, installs the Hugo version you configured, and runs the build command. If the build fails — a syntax error in a template, a corrupted file — Cloudflare keeps the previous version of the site live and logs the error. The site never goes down because of a broken build.\nIf the build passes, Cloudflare compares the generated files with the ones already distributed across the network. Only the files that changed get updated — if you published a new post, only the affected pages (the post, the listing, the RSS feed) are redistributed. The rest of the site stays as it was, served from cache.\nThe end result is that the author writes in a friendly web editor and, two minutes later, the content is available to the entire world on a site that loads in milliseconds. No server to maintain, no database to optimize, no fear of attacks. If the author publishes something wrong, just edit or revert — the full history is on GitHub.\nGetting started From here on, every step has real commands you\u0026rsquo;ll run in the terminal. If something goes wrong, stop and read the error message before moving on — most problems at this stage are typos or wrong paths.\nPrerequisites You\u0026rsquo;ll need four things installed on your machine before you begin:\nGit — the version control system. If you\u0026rsquo;re on a Mac, you probably already have it (type git --version in the terminal to check). On Linux, sudo apt install git does the job. On Windows, download it from git-scm.com.\nHugo — the site generator. On Mac with Homebrew: brew install hugo. On Linux, download the .deb from the releases page on Hugo\u0026rsquo;s GitHub. On Windows, Chocolatey or Scoop install it with a single command. Confirm with hugo version — you need version 0.128 or higher, and the extended build (which is the default when installing through package managers).\nA GitHub account — free, at github.com. If you work in development, you already have one. If not, create one now.\nA Cloudflare account — free, at dash.cloudflare.com. You\u0026rsquo;ll be using the free plan, which is more than enough for what we\u0026rsquo;re doing.\nNothing else to install. Pages CMS runs in the browser and requires no local setup.\nCreating the repository on GitHub Go to github.com/new and create a new repository:\nRepository name: pick something short with no spaces, like my-blog or the slug of your domain. Visibility: Private (your code doesn\u0026rsquo;t need to be public). Initialize with: check \u0026ldquo;Add a README file.\u0026rdquo; After creating it, clone the repository to your machine. Open the terminal and type:\ncd ~/projects git clone git@github.com:YOUR-USERNAME/my-blog.git cd my-blog Replace YOUR-USERNAME with your GitHub username and my-blog with the name you chose. If the ~/projects folder doesn\u0026rsquo;t exist, create it with mkdir ~/projects first.\nInstalling Hugo and choosing a theme With the terminal open inside your repository directory, create the Hugo site structure:\nhugo new site . --force The --force is necessary because the directory already exists (it has the GitHub README). Hugo will create several folders — content/, layouts/, static/, themes/ — and a hugo.toml file with minimal configuration.\nNow add the theme. In our case we\u0026rsquo;re using Terminal, a theme with a command-line aesthetic that fits a tech blog:\ngit submodule add https://github.com/panr/hugo-theme-terminal.git themes/terminal The git submodule add command downloads the theme and registers it as a dependency of your repository. This matters: when Cloudflare clones the repo to run the build, it will know it needs to download the theme as well.\nChoosing a theme is a decision you can change later, but it\u0026rsquo;s easier to get right the first time. Hugo\u0026rsquo;s theme directory at themes.gohugo.io has hundreds of options. For a news or magazine blog, Mainroad is a solid pick. For technical documentation, Docsy. For something minimalist, PaperMod. Browse around, check the demos, and pick one that looks like what you want — the installation is always the same git submodule add command with the theme\u0026rsquo;s URL.\nConfiguring the site Delete the auto-generated hugo.toml and create a new one with your site\u0026rsquo;s configuration. Each theme has its own options, but the basic structure is similar. Here\u0026rsquo;s a working example for the Terminal theme:\nbaseURL = \u0026#34;https://your-domain.com/\u0026#34; title = \u0026#34;Your blog name\u0026#34; languageCode = \u0026#34;en\u0026#34; defaultContentLanguage = \u0026#34;en\u0026#34; theme = \u0026#34;terminal\u0026#34; paginate = 5 [params] contentTypeName = \u0026#34;posts\u0026#34; themeColor = \u0026#34;orange\u0026#34; showMenuItems = 3 fullWidthTheme = false centerTheme = true subtitle = \u0026#34;Your tagline here\u0026#34; [params.logo] logoText = \u0026#34;Your blog name\u0026#34; [languages] [languages.en] languageName = \u0026#34;English\u0026#34; title = \u0026#34;Your blog name\u0026#34; [[languages.en.menu.main]] name = \u0026#34;Home\u0026#34; identifier = \u0026#34;home\u0026#34; url = \u0026#34;/\u0026#34; weight = 1 [[languages.en.menu.main]] name = \u0026#34;Posts\u0026#34; identifier = \u0026#34;posts\u0026#34; url = \u0026#34;/posts\u0026#34; weight = 2 [[languages.en.menu.main]] name = \u0026#34;About\u0026#34; identifier = \u0026#34;about\u0026#34; url = \u0026#34;/about\u0026#34; weight = 3 [markup] [markup.tableOfContents] startLevel = 2 endLevel = 3 ordered = false Replace the obvious values — baseURL, title, subtitle, logoText — with your own. The baseURL will change when you set up the final domain, but for now you can leave anything in there; what matters is that it\u0026rsquo;s not empty.\nThe showMenuItems parameter controls how many items appear in the main menu before the rest are tucked into a submenu. If you have three menu items, set it to 3. If you add a fourth later, update it to 4.\nFirst test post Create the folder structure for posts and the About page:\nmkdir -p content/posts Create the file content/posts/first-post.md with the following content:\n--- title: \u0026#34;My first post\u0026#34; date: 2026-03-26T10:00:00 description: \u0026#34;A test post to verify everything works.\u0026#34; tags: - \u0026#34;test\u0026#34; slug: why-leave-wordpress-and-what-to-build-instead-with-hugo-pages-cms-and-cloudflare draft: false toc: false --- If you\u0026#39;re reading this, Hugo is working. ## A test heading Here\u0026#39;s a normal paragraph with **bold** and *italic*. ### A sub-heading And a list: - Item one - Item two - Item three Done. If this rendered correctly, the theme is configured and we can move on. Also create the page content/about.md:\n--- title: \u0026#34;About\u0026#34; slug: why-leave-wordpress-and-what-to-build-instead-with-hugo-pages-cms-and-cloudflare draft: false --- Write a short description about yourself or your blog here. This page is accessible from the main menu. Testing locally before publishing Run Hugo\u0026rsquo;s development server:\nhugo server -D The -D flag includes posts marked as draft. Open http://localhost:1313 in the browser and you should see the site with the theme applied, the menu working, and the test post in the listing.\nBrowse around. Click the post to make sure it opens. Check that the menu shows the items you configured. If something isn\u0026rsquo;t right, edit hugo.toml and save — Hugo reloads automatically and the browser refreshes on its own.\nWhen you\u0026rsquo;re satisfied, it\u0026rsquo;s time to push to GitHub:\ngit add . git commit -m \u0026#34;Initial setup: Hugo + theme + first post\u0026#34; git push From this point on, your site exists as code on GitHub. In the next section, we\u0026rsquo;ll put it live.\nGoing live with Cloudflare So far, the site only exists on your machine and on GitHub. Nobody else can see it. Cloudflare is what will turn the repository into a public site, accessible from any browser in the world.\nConnecting the repository Go to dash.cloudflare.com and, in the sidebar, click Workers \u0026amp; Pages. Click Create application and choose the option to import a repository from GitHub. If this is your first time connecting Cloudflare to GitHub, it will ask you to install the \u0026ldquo;Cloudflare Workers and Pages\u0026rdquo; app on your account. During installation, GitHub asks which repositories Cloudflare should have access to — you can grant access to all of them or select only your blog\u0026rsquo;s repository. I recommend selecting only what you need; you can add others later.\nOne detail that trips up a lot of people: if you create a new repository after you\u0026rsquo;ve already done this setup, Cloudflare won\u0026rsquo;t see it automatically. You need to go back to the GitHub App settings (at github.com/settings/installations), click \u0026ldquo;Configure\u0026rdquo; next to \u0026ldquo;Cloudflare Workers and Pages,\u0026rdquo; and add the new repository to the permissions list.\nWith the repository visible, select it and proceed to the build configuration screen.\nConfiguring the build The configuration screen asks for a few pieces of information. Cloudflare\u0026rsquo;s interface changes fairly often, so the exact field names may vary, but the concept is always the same:\nThe Build command is the command Cloudflare will run to generate the site. Use:\nhugo --minify The --minify flag is optional but reduces the size of the generated HTML by stripping unnecessary whitespace and line breaks. It doesn\u0026rsquo;t affect the site\u0026rsquo;s appearance, just shrinks the file sizes.\nYou\u0026rsquo;ll need a wrangler.toml file at the root of your repository so Cloudflare knows where to find the build output. Create the file with this content:\nname = \u0026#34;your-project-name\u0026#34; compatibility_date = \u0026#34;2026-03-22\u0026#34; [assets] directory = \u0026#34;./public\u0026#34; The [assets] section with directory = \u0026quot;./public\u0026quot; tells Cloudflare that the content to be published lives in the public/ folder — which is exactly where Hugo places the generated HTML.\nIn the environment variables settings (usually hidden behind an \u0026ldquo;Advanced settings\u0026rdquo; button or similar), add a variable called HUGO_VERSION with the value of the version you installed on your machine — for example, 0.158.0. Without this variable, Cloudflare will use an old Hugo version and the build may fail silently or generate a site that looks different from what you saw locally.\nCommit and push the wrangler.toml:\ngit add wrangler.toml git commit -m \u0026#34;Add wrangler.toml for Cloudflare\u0026#34; git push The first deploy (and the errors you\u0026rsquo;ll run into) Click Deploy and watch the log. Cloudflare will clone the repository, install Hugo, run the build, and publish the result. The first deploy takes between one and two minutes.\nIf everything goes well, the log ends with a success message and a URL in the format your-project-name.your-subdomain.workers.dev. Open it in the browser and check — the site should be identical to what you saw at localhost:1313.\nBut it\u0026rsquo;s worth being prepared for what usually goes wrong on the first attempt, because almost nobody gets it right the first time:\n\u0026ldquo;Unable to locate config file\u0026rdquo; means Cloudflare is running Hugo in the wrong directory. Check that the \u0026ldquo;Path\u0026rdquo; field in the build configuration is set to / (the repository root) and not pointing to some subdirectory.\n\u0026ldquo;Error building site: template failed\u0026rdquo; usually indicates a version mismatch between Hugo and the theme. Older themes use functions that Hugo removed in recent versions. Confirm that the HUGO_VERSION variable is set and that the value matches the version that works on your machine.\n\u0026ldquo;Authentication error\u0026rdquo; during deploy may indicate that the automatically generated API token doesn\u0026rsquo;t have the necessary permissions. If this happens, try recreating the project from scratch — sometimes Cloudflare\u0026rsquo;s interface stumbles on the first setup and works perfectly on the second.\nThe key thing to know is that Hugo\u0026rsquo;s build and the Cloudflare deploy are separate steps. If the log shows \u0026ldquo;Build command completed\u0026rdquo; followed by an error in the deploy, the problem isn\u0026rsquo;t with your site — it\u0026rsquo;s in the communication between Cloudflare and your account. If the error appears during the build, before \u0026ldquo;Build command completed,\u0026rdquo; the problem is in the Hugo configuration or the theme.\nOnce the first deploy works, all subsequent ones are automatic. Every push to the repository — whether from your terminal or from Pages CMS — triggers a new build and deploy without you touching anything. The entire cycle, from commit to updated site, takes between one and two minutes.\nWith the site live, update the baseURL in hugo.toml to the URL that Cloudflare generated, commit, and push. This detail is easy to forget, but Hugo uses baseURL to generate internal links, the sitemap, and the RSS feed — if it\u0026rsquo;s wrong, those features will point to the wrong place.\nConnecting Pages CMS The site is live and automatic deploys work. What\u0026rsquo;s missing is the piece that makes all of this usable by someone who doesn\u0026rsquo;t know (and doesn\u0026rsquo;t want to know) what Git is: the web editor.\nThe .pages.yml file — the map of your content Pages CMS doesn\u0026rsquo;t guess your site\u0026rsquo;s structure. You need to tell it where the posts live, which fields each post has, and where images are stored. All of that goes in a single file called .pages.yml, at the root of the repository.\nA working example for a Hugo blog:\nmedia: input: static/img output: /img content: - name: posts label: Posts type: collection path: content/posts view: fields: [title, date] fields: - name: title label: Title type: string - name: date label: Date type: date - name: description label: Summary type: string - name: cover label: Cover image type: image - name: tags label: Tags type: string list: true - name: toc label: Table of contents type: boolean default: true - name: draft label: Draft type: boolean default: false - name: body label: Content type: code options: language: markdown Each block inside fields corresponds to a field the author will see in the editor. The type defines the control — string is a simple text input, date shows a date picker, image allows uploads, boolean is an on/off toggle, and code with the language: markdown option provides a text editor with Markdown syntax highlighting. The labels can be in any language — the Portuguese version of this blog uses localized labels like \u0026ldquo;Título\u0026rdquo; and \u0026ldquo;Resumo\u0026rdquo; and they work just the same.\nThe media section deserves attention. The input is the path inside the repository where images are saved — static/img. The output is the path as Hugo will serve those files on the final site — /img. This distinction exists because Hugo serves everything inside static/ from the site root, stripping the static prefix from the URL. If you set media: static/img without separating input and output, images will work in the editor but return 404 on the published site.\nCommit and push the file:\ngit add .pages.yml git commit -m \u0026#34;Add Pages CMS configuration\u0026#34; git push Accessing the editor for the first time Open app.pagescms.org and log in with your GitHub account. Pages CMS will ask for permission to access your repositories — just like Cloudflare, it works as a GitHub App, and you can restrict access to specific repositories.\nAfter authorizing, the main screen shows the list of projects. Select your blog\u0026rsquo;s repository and Pages CMS will read the .pages.yml to build the interface. If everything is set up correctly, you\u0026rsquo;ll see \u0026ldquo;Posts\u0026rdquo; in the sidebar and, when you click it, the list of existing posts in the repository.\nIf the repository doesn\u0026rsquo;t show up in the list, the problem is the same as with Cloudflare: the Pages CMS GitHub App doesn\u0026rsquo;t have access to the repository. Go to github.com/settings/installations, find the Pages CMS app, and add the repository.\nCreating and publishing a post from the browser Click \u0026ldquo;Add an entry\u0026rdquo; in the upper right corner. The editor will show the fields you defined in .pages.yml — title, date, summary, cover image, tags, and the Markdown content field.\nFill in the fields and write the post in the Markdown editor. The field uses CodeMirror with syntax highlighting, so headings, links, bold, and code appear colored as you type. It\u0026rsquo;s not a WYSIWYG editor with live visual preview, but for anyone comfortable with Markdown the experience is smooth.\nWhen you\u0026rsquo;re done, click \u0026ldquo;Save.\u0026rdquo; Pages CMS will create the Markdown file in the repository with the filled-in front matter and the post content. That commit triggers the webhook to Cloudflare, which runs the Hugo build and publishes the updated version of the site. In one to two minutes, the post is live.\nA note about deleting posts: Pages CMS has a known bug in the delete function — the post appears to be removed in the dashboard, but the file remains in the repository, and the post keeps showing up on the site. Until this is fixed, the safe way to \u0026ldquo;unpublish\u0026rdquo; a post is to edit it and toggle the Draft field on. Hugo ignores posts marked as draft in the production build, so the post disappears from the site without needing to delete the file. If you truly need to remove a post from the repository, do it from the terminal with git rm and push.\nSetting up the cover image The cover field in .pages.yml lets the author upload an image to be used as the post\u0026rsquo;s featured image — in the listing, at the top of the article, and in social media sharing cards. The exact behavior depends on the theme: Terminal uses the cover field in front matter, Mainroad uses thumbnail, and other themes may have different names. Check the documentation of the theme you chose for the correct field name.\nWhen clicking the image field in the editor, Pages CMS lets you select an existing image from the media folder or upload a new one. The image is saved to the repository at the path configured in media.input, and the path written to the post\u0026rsquo;s front matter follows the pattern defined in media.output.\nThe image path detail This is the most common error in the integration between Pages CMS and Hugo, and it\u0026rsquo;s worth reinforcing because it\u0026rsquo;s a silent one — everything appears to work in the editor, but images show up broken on the site.\nHugo serves files from the static/ folder directly at the site root. A file at static/img/photo.jpg is accessible at yoursite.com/img/photo.jpg, not at yoursite.com/static/img/photo.jpg. If Pages CMS writes the full repository path in the front matter (/static/img/photo.jpg), the browser will request a file that doesn\u0026rsquo;t exist at that path and show a broken image.\nThe solution is the separation between input and output in the media configuration of .pages.yml that we showed above. The input: static/img tells Pages CMS where to save the file in the repository. The output: /img tells it which path to write in the post\u0026rsquo;s front matter. When Hugo generates the site, the file is in static/img/ and the reference in the HTML points to /img/ — and everything lines up.\nCustom domain The site works on the .workers.dev subdomain that Cloudflare generated, but nobody is going to take a blog seriously with a URL that looks like a test project. Setting up a custom domain is the finishing touch that turns the setup into a real site.\nPointing the DNS If your domain is already on Cloudflare (as nameserver or with active proxy), the process is straightforward. In the Cloudflare dashboard, go to DNS → Records and add a CNAME record:\nType: CNAME Name: the subdomain you want (for example, blog for blog.yourdomain.com, or @ to use the root domain) Target: the URL that Cloudflare generated for your project, without the https:// — something like your-project-name.your-subdomain.workers.dev Proxy status: Proxied (orange cloud active) Then go to Workers \u0026amp; Pages, open your site\u0026rsquo;s project, and under Settings look for the Custom Domains section. Add the domain — for example, blog.yourdomain.com. Cloudflare will verify that the CNAME exists and associate the domain with the project.\nIf your domain is registered elsewhere (GoDaddy, Namecheap, Porkbun), you have two options. The simplest is to transfer the nameservers to Cloudflare — the free plan includes DNS hosting, and propagation takes anywhere from minutes to a few hours. The alternative is to create the CNAME in your current registrar\u0026rsquo;s panel pointing to the .workers.dev address, but in that case you lose Cloudflare\u0026rsquo;s proxy and some cache optimizations.\nDNS propagation can take anywhere from a few minutes to 24 hours, depending on the configured TTL and the provider. In practice, with Cloudflare as the nameserver, the change usually reflects in under five minutes.\nAutomatic HTTPS Nothing to do here. Cloudflare automatically generates and renews an SSL certificate for your custom domain. As soon as DNS propagates and Cloudflare recognizes the domain, the site starts responding over HTTPS with no additional configuration, no certificate to install, and no manual renewal.\nIf someone accesses the site over HTTP, Cloudflare automatically redirects to HTTPS. This is enabled by default — but if for some reason it isn\u0026rsquo;t, go to SSL/TLS → Edge Certificates and turn on \u0026ldquo;Always Use HTTPS.\u0026rdquo;\nAfter confirming that the domain is working, update the baseURL in hugo.toml to the final address, commit, and push. This is the last time you need to touch that field.\nWhat about the things WordPress did out of the box? Anyone coming from WordPress is used to solving everything with plugins. Search, comments, contact forms, scheduled posts — everything is one click away in the dashboard. On a static site, these features don\u0026rsquo;t come for free, but they\u0026rsquo;re not impossible either. Some are surprisingly easy; others require compromises.\nSearch on a static site — how it works without a database The first reaction from anyone discovering static sites is usually: \u0026ldquo;but how does search work without a database?\u0026rdquo; The answer is that search runs entirely in the visitor\u0026rsquo;s browser.\nDuring the build, Hugo can generate a JSON index file containing the titles, summaries, and (optionally) the full content of every post. When the visitor opens the search page and types something, a JavaScript file loads that index and filters the results right there, with no server request at all.\nThe most common solution is Fuse.js — a lightweight fuzzy search library that works well for small to mid-sized blogs. The visitor types, results appear instantly as they keystroke. Several Hugo themes already ship with Fuse.js search built in; you just need to enable it in the configuration.\nThe thing to watch is the index size. If the blog has 50 posts, the JSON weighs a few kilobytes and loads imperceptibly. With 500 posts indexing full content, the file can grow past 2 or 3 MB — the visitor needs to download all of that before search works. The solution for large sites is Pagefind, a tool that generates a fragmented binary index and only downloads the relevant chunks for each query. It integrates with Hugo without trouble and scales to thousands of pages without the visitor noticing any slowdown.\nComments without a backend — Giscus, Utterances, and alternatives Comments are the feature you miss most when leaving WordPress, and at the same time the one that causes the most headaches on any platform. Spam, moderation, GDPR, performance — WordPress\u0026rsquo;s native comment system handles the basics, but any serious installation ends up resorting to plugins like Akismet or Disqus anyway.\nOn a static site, comments need an external service. The most popular free options:\nGiscus uses GitHub\u0026rsquo;s Discussions system as its backend. Each blog post becomes a discussion in the repository, and comments are threads within it. The visitor needs a GitHub account to comment, which is a barrier for non-technical audiences but a natural spam filter for tech blogs. Integration is a JavaScript snippet in the theme template.\nUtterances works similarly but uses GitHub Issues instead of Discussions. Each post generates an issue, and comments are replies on it. The experience is nearly identical to Giscus, with the same limitation of requiring a GitHub account.\nDisqus is the most universal — it accepts login through various social networks and email — but it injects heavy scripts, tracks visitors, and shows ads on the free plan. If the blog\u0026rsquo;s audience isn\u0026rsquo;t technical and needs a familiar commenting experience, Disqus works, but the cost is performance and privacy.\nFor tech blogs, Giscus is the best choice. For blogs aimed at a general audience, the best option may be to simply not have comments and direct the conversation to social media — which is what most major news sites do today. There is also a middle ground: if you already run a server and want full control over your visitors\u0026rsquo; data, a self-hosted comment system like Isso provides lightweight comments with no tracking and no third-party dependencies.\nRelated posts — what Hugo offers natively Hugo has a native related posts system that works without any plugin. It analyzes tags, categories, publication date, and front matter keywords to calculate a relevance score between posts and display a list of suggestions at the end of each article.\nThe configuration goes in hugo.toml:\n[related] includeNewer = true threshold = 80 toLower = true [[related.indices]] name = \u0026#34;tags\u0026#34; weight = 100 [[related.indices]] name = \u0026#34;categories\u0026#34; weight = 80 [[related.indices]] name = \u0026#34;date\u0026#34; weight = 10 The threshold sets the minimum score for a post to show up as related — the higher the value, the more restrictive. The weights on each index control what matters most in the comparison: in this example, shared tags are worth more than categories, and publication date has minimal influence.\nThe display depends on the theme. Some themes already include a related posts block in the post layout; others require you to add the snippet to the template. In the worst case, it\u0026rsquo;s a few lines of Go template to insert the list — Hugo\u0026rsquo;s documentation covers this in detail.\nScheduled posts — publishing in the future without a cron job Hugo has native support for future dates. If a post\u0026rsquo;s front matter has a date later than the moment of the build, Hugo simply doesn\u0026rsquo;t include the post in the generated site. The post exists in the repository but is invisible to visitors.\nThe problem is that someone needs to trigger the build after the date arrives. In WordPress, the internal cron handles this automatically. On a static site, the build only happens when there\u0026rsquo;s a push to the repository or when someone triggers it manually.\nThe simplest solution is a GitHub Action on a schedule. An Action that runs once a day (or every hour, depending on your needs) makes an empty push to the repository, which triggers the build on Cloudflare. If there are posts whose date has already passed, they appear on the site; if not, the build finishes without changing anything.\nname: Publish scheduled posts on: schedule: - cron: \u0026#39;0 */6 * * *\u0026#39; jobs: trigger: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: | git config user.name \u0026#34;github-actions\u0026#34; git config user.email \u0026#34;actions@github.com\u0026#34; git commit --allow-empty -m \u0026#34;Trigger build for scheduled posts\u0026#34; git push This workflow runs every six hours and makes an empty commit — enough to trigger the build without changing any content. The granularity is adjustable; if you need posts to go live with hourly precision, change the cron to run every hour. For most blogs, once a day is enough.\nAds — where and how to insert them in layouts and posts Monetization with ads works normally on a static site — the HTML generated by Hugo is identical to what any dynamic CMS would produce, and ad networks (Google AdSense, Ezoic, Mediavine) can\u0026rsquo;t tell the difference. What changes is where you place the code.\nIn WordPress, plugins like Ad Inserter let you position ads without touching code. In a Hugo site, ads go directly in the theme\u0026rsquo;s templates. There are two natural places:\nFor ads in the layout (sidebar, header, footer, between posts in the listing), you edit the theme\u0026rsquo;s partials. Most well-structured themes have files like layouts/partials/header.html, layouts/partials/footer.html, and layouts/partials/sidebar.html. The ad code — usually a JavaScript snippet provided by the ad network — goes straight into these files, in the desired position.\nFor ads inside post content (for example, after the third paragraph), the cleanest approach is to create a shortcode. Create the file layouts/shortcodes/ad.html with the ad code, and in the post\u0026rsquo;s Markdown insert {{\u0026lt; ad \u0026gt;}} wherever you want the ad to appear. This keeps content separate from monetization — if you switch ad networks, you change one file instead of editing hundreds of posts.\nContact form — options without a server A static site doesn\u0026rsquo;t process forms — there\u0026rsquo;s no backend to receive the data. But specialized services exist that solve this with a single line of configuration:\nFormspree is the simplest. You create a normal HTML form on your site, point the action to the Formspree endpoint, and submissions arrive in your email. The free plan allows 50 submissions per month, which is more than enough for a blog.\nFormspark and Basin work similarly, with free plans of varying capacity. Cloudflare Workers can also process forms, which keeps everything within the same ecosystem — but it requires writing a bit of JavaScript.\nFor most blogs, a simple form with Formspree does the job without any complication. Add a page content/contact.md with the form in plain HTML in the Markdown body — Hugo renders HTML inside Markdown without issues.\nRSS — the feed that comes ready out of the box This is the shortest good news in the post: Hugo generates RSS automatically. Nothing to install, nothing to configure. The feed is available at /index.xml by default and includes all posts with title, date, summary, and link.\nIf you want to customize what appears in the feed — for example, including the full content instead of just the summary, or limiting the number of items — Hugo lets you override the RSS template. But for most blogs, the default works perfectly from the very first build.\nJust add the link to the navigation menu or the site footer so readers can find it. RSS aggregators like Feedly, Inoreader, and NewsBlur recognize the format automatically.\nWhat I learned along the way Advantages I didn\u0026rsquo;t expect The most obvious advantage — speed — I already expected. A static site served from a CDN loads fast, end of story. What surprised me were the side benefits that only show up after using the setup for a while.\nContent versioning is the first one. In WordPress, if an editor overwrites a paragraph and saves, the previous version gets buried in a revision system that almost nobody knows how to use. With content in Git, every change is a commit with date, author, and diff. If someone publishes something wrong at two in the morning, reverting is a git revert — or, worst case, a click in GitHub\u0026rsquo;s history. This sounds like a technical detail until the day it saves your neck.\nOperational cost was the second surprise. It\u0026rsquo;s not just that hosting is free — it\u0026rsquo;s the total absence of infrastructure maintenance. No server to update, no PHP to keep compatible, no MySQL to optimize, no cache plugin to configure. The site simply exists as a set of static files distributed around the world. The monthly infrastructure bill is zero. The monthly hours spent putting out fires is also zero.\nThe third was portability. The site\u0026rsquo;s entire content is a Git repository with Markdown files. If Cloudflare disappears tomorrow, I move the deploy to Netlify, Vercel, or an Nginx on my own server in under an hour. If Pages CMS shuts down, the files are still on GitHub — I just need another editor, or any text editor at all. No piece of the system is irreplaceable, and none of them holds my data hostage.\nLimitations you need to know about The most concrete limitation is the absence of native dynamic features. Everything that depends on server-side processing — advanced search, comments, forms, restricted areas — needs an external service or a workaround. The solutions exist (and we covered the main ones in this post), but each one adds a dependency and a configuration point. In WordPress, it\u0026rsquo;s a plugin. Here, it\u0026rsquo;s an external service with its own account, its own documentation, and its own limits.\nPages CMS, while functional, is still a young project maintained essentially by one person. There is no rich-text editor — the content field is a code editor with Markdown syntax highlighting, which is fine for anyone who knows the syntax but unworkable for a writer who has never seen a ## in their life. The delete function has a bug that leaves the file in the repository even after confirming the deletion. These are problems you can work around, but they show that the tool isn\u0026rsquo;t ready to hand off to a non-technical client without supervision.\nThe build isn\u0026rsquo;t instant. Between clicking \u0026ldquo;save\u0026rdquo; and the post appearing on the site, one to two minutes go by. For a blog, that\u0026rsquo;s irrelevant. For a news site that needs to publish with breaking-news urgency, it can be an annoyance. Hugo compiles in under a second — the latency is entirely in Cloudflare provisioning the environment and distributing the files.\nEditing the theme requires working with Go templates. Hugo\u0026rsquo;s template system is powerful, but the syntax isn\u0026rsquo;t intuitive for anyone coming from WordPress\u0026rsquo;s PHP — I wrote a separate post about the mental model shift from WordPress themes to Hugo themes that covers this in detail. Simple things like changing the position of an element on the page or adding a custom field to the post layout mean opening an .html file full of {{ }} and understanding the logic of partials, blocks, and contexts. The learning curve isn\u0026rsquo;t steep, but it exists, and it shows up precisely when you want to do something the theme didn\u0026rsquo;t anticipate.\nWho this approach makes sense for (and who it doesn\u0026rsquo;t) This stack makes sense for personal and professional blogs, documentation sites, portfolios, institutional sites, and small news sites. It makes especially good sense when the author is technical or has access to someone technical who can handle the initial setup — because the configuration requires a terminal, Git, and reading documentation. Once configured, the day-to-day is simple enough for anyone who knows how to fill in a web form.\nIt doesn\u0026rsquo;t make sense for sites that depend on heavy real-time interactivity: e-commerce with shopping carts, web applications with user login, discussion forums, e-learning platforms with student progress tracking. These features need a backend, and trying to replicate them with external services glued onto a static site is swimming against the current.\nIt also doesn\u0026rsquo;t make sense — at least for now — when the authors are completely non-technical and nobody is available to sort out the inevitable stumbles. Pages CMS is friendly, but it\u0026rsquo;s not WordPress. There\u0026rsquo;s no dashboard with metrics, no visual preview before publishing, no trash bin that recovers accidentally deleted posts. Whoever operates the site needs a minimum level of comfort with digital tools and, ideally, access to someone who can open a terminal when things go wrong.\nFor everything else — for anyone who wants a fast, secure site that\u0026rsquo;s cheap to run and doesn\u0026rsquo;t require babysitting a server around the clock — this combination of Hugo, Pages CMS, and Cloudflare delivers more than enough with less hassle than any dynamic alternative I\u0026rsquo;ve ever managed.\nIf you want to understand where the real limits of this free stack are — and what to do when you hit them — I wrote a dedicated post on the subject. And when the number of posts grows and keeping tags and meta descriptions consistent becomes a problem, Hugin uses AI to classify and summarize Hugo posts automatically.\n","date":"20/03/2026","lang":"en","tags":["self-hosting","hugo","cloudflare","static-site","homelab","wordpress"],"title":"Why leave WordPress — and what to build instead with Hugo, Pages CMS, and Cloudflare","url":"https://devops.sarmento.org/en/posts/why-leave-wordpress-and-what-to-build-instead-with-hugo-pages-cms-and-cloudflare/"},{"categories":["Meta"],"content":"This is the English side of /var/log/janio — a blog about Linux, macOS, self-hosting, homelab, and the small tools that make daily life in the terminal a little better.\nMost of the content lives on the Portuguese side for now. Posts will be translated or written directly in English as the blog grows. If you read Portuguese, there\u0026rsquo;s a lot more waiting for you on the other side of the language switcher.\nStay tuned.\n","date":"19/03/2026","lang":"en","tags":["linux","self-hosting","homelab"],"title":"Hello, World!","url":"https://devops.sarmento.org/en/posts/hello-world/"},{"categories":null,"content":"Janio Sarmento — sysadmin, 53, Brazilian working from home for the world. I manage Linux servers, LXC containers, email that doesn\u0026rsquo;t land in spam, and cats that won\u0026rsquo;t get off the keyboard.\nThis blog documents the daily life of someone keeping infrastructure running, including the things that break (and how to fix them).\n","date":"01/01/0001","lang":"en","tags":null,"title":"About","url":"https://devops.sarmento.org/en/about/"}]