[{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/articles/","section":"Articles","summary":"","title":"Articles","type":"articles"},{"content":"Let me be clear: I\u0026rsquo;m not lazy. I\u0026rsquo;m really lazy. There\u0026rsquo;s a difference. A lazy person skips the work. A really lazy person spends a whole Saturday building a machine so they never have to do that work again.\nIt suits the name. Bufon means jester, and the jester was the one person at court who never carried anything. He juggled, told the king the truth and let the servants do the rest. My servants are a GitHub workflow and a cron job. They don\u0026rsquo;t get paid either.\nIn Get on the bike! I wrote that you should do something by hand once, script it the second time and automate it when it annoys you again. Publishing posts had reached step three.\nThe manual way # Everything that runs on bufon.nl lives in one Git repo: Traefik, mailcow tweaks, a Claude sandbox and the website itself. The site is built with Hugo and served by nginx in a container.\nWriting a post used to look like this:\nCreate a folder with an index.md. Copy the front matter from an older article and fix the title, date, slug and tags. Make a cover image. Write, commit, push, open a pull request and merge it. SSH into the server, pull and rebuild the container. None of it is hard. All of it is fiddly. Forget the slug, or put the wrong date in the front matter, and the post ends up in the wrong place.\nThe new way # Now it looks like this:\nI write a prompt. That\u0026rsquo;s the list. Everything below happens without me.\nOne prompt # I start a Claude Code session on the repo and give it a short, fixed prompt:\nWrite a new article for my Hugo site in website/site/content/articles/. Topic: \u0026lt;what it\u0026#39;s about, main points, stories or examples\u0026gt; Title: \u0026lt;title, or \u0026#34;suggest one\u0026#34;\u0026gt; Tags: \u0026lt;e.g. Automation, Ansible\u0026gt; Category: \u0026lt;e.g. Infrastructure\u0026gt; Follow the existing articles exactly: - a page bundle: articles/\u0026lt;folder_name_with_underscores\u0026gt;/index.md - the same front matter: title, date (today), draft: false, description, slug (with-dashes), tags, categories - match my tone: first person, short paragraphs, ## headings Commit it on a new branch and open a pull request to main. The prompt is the documentation. It says where posts go, what the front matter looks like and how I want them to read. Claude reads a few existing articles, writes the new one in the same shape and opens a pull request.\nThe repo also has an AGENTS.md with house rules for AI agents: commits are mine, no \u0026ldquo;generated by\u0026rdquo; lines in commits or PR descriptions, and leave the cover alone, because the pipeline makes it.\nThe cover draws itself # My covers all follow one template: a dark 1280 by 720 card, a title with one word in amber, three numbered steps and a line of code at the bottom. They\u0026rsquo;re SVG files, so they\u0026rsquo;re just text. And text is something Claude is good at.\nWhen a pull request adds an article without a cover, the first job in the pipeline:\ngives Claude the article and three existing cover SVGs as examples lets Claude write a new SVG in the same style, with its own caption, steps and a small line icon renders it to PNG with the same IBM Plex fonts the other covers use shows Claude the PNG, so it can fix text that runs out of its box, and render again adds featureimage and alt to the front matter and commits the SVG, the PNG and the front matter to the pull request The job may only touch those three files. If anything else changed, it fails.\nIf I don\u0026rsquo;t like a cover, I change the words in the SVG and push. The pipeline renders the PNG again. The cover on this article went through exactly that pipeline.\nChecks before anything goes live # After the cover, four checks run on the new commit:\nScope. Does the PR only touch articles and cover images? Or does it also change Traefik, mailcow, the Dockerfile or the workflows themselves? Hugo build. The site is built with the same Dockerfile the server uses. If it builds in the pipeline, it builds on the server. Front matter. A small script checks every changed article: title, date, description, slug, tags, categories, a cover image and its alt text are all there, and the slug is lowercase-with-dashes. It also catches a date in the future. Hugo quietly skips future posts, so the build is green and the article just never appears. Article review. Claude reads the diff, looks at the cover and answers one question: is this ready to publish as is? It looks for spelling mistakes, broken Markdown, leftover TODOs, text that overflows the cover, and anything that shouldn\u0026rsquo;t be public, like hostnames, IP addresses or credentials. It posts its verdict as a comment, with a list of issues if it says no. The review is not a rubber stamp. The first version of this article got rejected twice. Once because the cover still described the old flow. Once because of a claim about SonarQube: the robot that wrote the article said the free tier of SonarCloud was for public repos only. The robot that reviewed it said that was out of date. It was right. SonarQube Cloud\u0026rsquo;s free tier covers private repos up to 50,000 lines of code. I didn\u0026rsquo;t add it anyway: it analyses code, not prose.\nAuto-merge, but only for content # If the PR only touches content and all checks are green, the workflow squash-merges it. It only merges the exact commit that was checked. Push something new and the checks start over.\nAnything else waits for me. A change to the proxy, the mail server or the pipeline gets a code review from Claude, and then sits there until I read it and merge it myself.\nThere\u0026rsquo;s one more safety net I didn\u0026rsquo;t build myself. The Claude GitHub Action refuses to run a workflow that a pull request has changed, until that version is on main. I found out the hard way: my first test of the cover job failed in thirty seconds. Annoying for five minutes, and exactly right. A pull request can\u0026rsquo;t rewrite the instructions the robot runs with.\nThe server pulls, it doesn\u0026rsquo;t get pushed to # The server keeps two copies of the repo: a working copy where I edit, and a deploy copy in /opt/bufon that the services run from. Nobody edits the deploy copy.\nEvery ten minutes a cron job checks GitHub for a new main. Most of the time there\u0026rsquo;s nothing, and it stops. When there is something, it does this:\nFast-forward only. If the deploy copy has local changes or commits that aren\u0026rsquo;t on GitHub, it refuses to pull and writes a line in its log. It never overwrites anything. Rebuild the site, and only the site. If anything under website/ changed, it runs docker compose up -d --build. Hugo builds the static site, the output goes into a small nginx image, and Traefik routes to the new container. Leave the rest alone. Changes to Traefik or mailcow are pulled, but never restarted automatically. A bad merge shouldn\u0026rsquo;t be able to take my mail down. Those restarts are mine. I like the pull model. GitHub never needs a way into my server. The server reaches out with a read-only deploy key that can fetch this one repo and nothing else. No open SSH port for a pipeline, no key that can push.\nLeast privilege for the robot # Giving an AI agent access to GitHub is giving it a key. So I asked the same question I\u0026rsquo;d ask for any service account: what is the least it needs?\nThe answer is two repos. The Claude GitHub App is installed with \u0026ldquo;Only select repositories\u0026rdquo;, and I selected two private ones: this bufon repo and a small FastAPI test project. Nothing else on my account. Not my public Puppet modules, and not any repo I create later. A new repo stays invisible to Claude until I tick its box.\nIf something goes wrong in a session, or a prompt gets twisted into doing something I didn\u0026rsquo;t ask for, the damage stays inside those two repos.\nWhy not a personal access token? # The obvious shortcut is a personal access token. Create one, paste it in, done. That\u0026rsquo;s exactly why I didn\u0026rsquo;t.\nA classic token with the repo scope is a key to every repo you can reach: your own, public and private, and those of every organisation you\u0026rsquo;re a member of. It can\u0026rsquo;t be limited to one repo. It doesn\u0026rsquo;t expire unless you tell it to. And everything it does looks like you did it.\nA fine-grained token is a lot better. You choose the repos and the permissions, and it has an expiry date. Set up carefully, it can be almost as tight as the app. But it\u0026rsquo;s still a long-lived secret that sits somewhere: in a config file, an environment variable or a CI secret. Someone has to rotate it, and anyone who copies it gets the same access for as long as it\u0026rsquo;s valid.\nThe GitHub App turns that around. I don\u0026rsquo;t hold a secret at all. GitHub hands out tokens that expire after an hour, the list of repos lives in my GitHub settings instead of on a disk, and I can take a repo away, or remove the app, with one click.\nClassic token Fine-grained token GitHub App Limit to some repos No, all of them Yes Yes Limit permissions Coarse scopes Per permission Per permission Expires Only if you set it Yes Every hour Secret you have to store Yes Yes No So yes, a token can be more privileged than the app, and a classic one almost always is.\nAuto-merge follows the same idea. Only content can merge itself. The worst a bad article can do is put a bad article on my site, and I can revert that in a minute. A PR that touches a workflow can\u0026rsquo;t merge itself, so the pipeline can\u0026rsquo;t be used to loosen its own rules.\nIt\u0026rsquo;s the same thinking behind the Claude sandbox on the server. That container only sees the folder it\u0026rsquo;s working in, and its firewall only lets it reach Anthropic, GitHub and npm. Everything else is dropped.\nWhat\u0026rsquo;s still manual # Not much, and what\u0026rsquo;s left is on purpose:\nThe idea. Claude can write the words, but the stories and opinions have to come from me. Even a jester needs his own jokes. The merge, for everything but articles. Infrastructure and pipeline changes need a human. Reading the result. An article can go live without me clicking anything, so I read it on the site afterwards. If I don\u0026rsquo;t like it, the fix is another prompt. The bottom line # The whole flow is a prompt, a pull request, a generated cover, four checks, an auto-merge and a pull on the server. From pull request to live page takes about fifteen minutes, and I don\u0026rsquo;t touch a terminal, an editor or a drawing tool once.\nThat\u0026rsquo;s not laziness. That\u0026rsquo;s really good laziness. Each step is small, and each one is a piece of road I only had to build once.\nIf you\u0026rsquo;re giving an agent access to your infrastructure, start with the question of what it actually needs. For me that was two repos, a pull request, and a hard line between content that may merge itself and infrastructure that may not.\n","date":"October 10, 2026","externalUrl":null,"permalink":"/articles/automate-my-posts-on-bufon/","section":"Articles","summary":"Let me be clear: I’m not lazy. I’m really lazy. There’s a difference. A lazy person skips the work. A really lazy person spends a whole Saturday building a machine so they never have to do that work again.\nIt suits the name. Bufon means jester, and the jester was the one person at court who never carried anything. He juggled, told the king the truth and let the servants do the rest. My servants are a GitHub workflow and a cron job. They don’t get paid either.\nIn Get on the bike! I wrote that you should do something by hand once, script it the second time and automate it when it annoys you again. Publishing posts had reached step three.\n","title":"Automate my posts on bufon.nl","type":"articles"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/automation/","section":"Tags","summary":"","title":"Automation","type":"tags"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/claude-code/","section":"Tags","summary":"","title":"Claude Code","type":"tags"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/github/","section":"Tags","summary":"","title":"GitHub","type":"tags"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/categories/infrastructure/","section":"Categories","summary":"","title":"Infrastructure","type":"categories"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/least-privilege/","section":"Tags","summary":"","title":"Least Privilege","type":"tags"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/categories/personal/","section":"Categories","summary":"","title":"Personal","type":"categories"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/personal/","section":"Tags","summary":"","title":"Personal","type":"tags"},{"content":"Identity · Cloud · Platform · Linux · DevSecOps · Windows · Network\n","date":"October 10, 2026","externalUrl":null,"permalink":"/","section":"Security-driven automation, whatever it runs on.","summary":"Identity · Cloud · Platform · Linux · DevSecOps · Windows · Network\n","title":"Security-driven automation, whatever it runs on.","type":"page"},{"content":"","date":"October 10, 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"People sometimes ask where the name Bufon comes from. Short answer: it\u0026rsquo;s a jester.\nThe fool # In Spanish (and a few other languages), bufón means jester, the court fool. Historically the jester was the one person allowed to tell the king uncomfortable truths without losing his head. Pointing out that the emperor\u0026rsquo;s firewall has no clothes felt like a fitting job description for someone working in security.\nAlas, poor Yorick # The most famous jester in literature is Yorick, from Shakespeare\u0026rsquo;s Hamlet. He never says a word in the play; by the time we meet him he\u0026rsquo;s a skull in Hamlet\u0026rsquo;s hand:\n\u0026ldquo;Alas, poor Yorick! I knew him, Horatio\u0026hellip;\u0026rdquo;\nAnd my name happens to be Yorick. So I didn\u0026rsquo;t so much pick a pseudonym as translate myself: Yorick, the jester, el bufón. Bufon.\nThe crown and the shield # That\u0026rsquo;s also why the logo has a crown and a shield. The crown is the court the jester serves. The shield is the day job: identity, access and keeping the castle secure.\nNow you\u0026rsquo;re in on the joke.\n","date":"October 10, 2026","externalUrl":null,"permalink":"/articles/why-bufon/","section":"Articles","summary":"People sometimes ask where the name Bufon comes from. Short answer: it’s a jester.\nThe fool # In Spanish (and a few other languages), bufón means jester, the court fool. Historically the jester was the one person allowed to tell the king uncomfortable truths without losing his head. Pointing out that the emperor’s firewall has no clothes felt like a fitting job description for someone working in security.\n","title":"Why Bufon?","type":"articles"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/activedirectory/","section":"Tags","summary":"","title":"ActiveDirectory","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/ai/","section":"Tags","summary":"","title":"AI","type":"tags"},{"content":"Ten years ago I moved from Windows to Linux, and later to the cloud. Recently I picked up Active Directory again, to migrate an environment to Windows Server 2025.\nI expected to spend weeks relearning. Instead, it felt like I\u0026rsquo;d never left.\nWhat didn\u0026rsquo;t change # The core of Active Directory is almost exactly where I left it:\nIt still lives and dies by DNS. Clients find domain controllers through SRV records. If DNS is wrong, everything else is wrong too, and the error messages will point you everywhere except DNS. Kerberos is still the backbone. Tickets, service principal names, delegation, clock skew. If the time is off, authentication is off. Replication still works the same way. Sites, site links, the KCC, USNs, and FSMO roles that still matter more than people think. Group Policy still processes in the same order. Local, site, domain, OU. And it\u0026rsquo;s still the place where ten years of \u0026ldquo;temporary\u0026rdquo; fixes go to live forever. If you understood these concepts ten years ago, you\u0026rsquo;re fine. The tooling around them got better. The protocols underneath did not need to.\nWhat did change # The real difference is in the defaults. Windows Server 2025 assumes you care about security, and it stops being polite about legacy.\nThe floor got raised # A Windows Server 2025 DC needs a domain and forest at Windows Server 2016 functional level or higher. Old forests stuck at 2008 R2 have homework before you can even add the first new DC. SYSVOL still replicating with FRS? That has to move to DFSR first. In older environments this is often the first surprise. There\u0026rsquo;s a new functional level for Windows Server 2025, and with it an optional 32k database page size. That\u0026rsquo;s the first change to the database page size since Active Directory was introduced. It\u0026rsquo;s forest-wide, and every DC has to support it. Secure by default, finally # LDAP signing and sealing are required by default on new deployments, and LDAP over TLS supports TLS 1.3. Confidential attributes can only be read or changed over an encrypted connection. The KDC no longer issues RC4 ticket-granting tickets. The old SupportedEncryptionTypes registry key is no longer honored, so encryption types belong in Group Policy. New computer accounts get random passwords instead of the old predictable default. SMB signing is required by default for outbound connections, and Credential Guard is on by default on capable hardware. Better tools for old problems # Windows LAPS is built in, with passphrases, image rollback detection and automatic account management. There\u0026rsquo;s no reason left for shared local admin passwords. Delegated Managed Service Accounts (dMSA) let you migrate classic service accounts to managed, randomized keys. Useful, but review who can create objects in your OUs. dMSA already had a well-publicized privilege escalation path (\u0026ldquo;BadSuccessor\u0026rdquo;). And if you run domain controllers on modern hardware or in the cloud, the Secure Boot certificate rollover matters too. I wrote about that in You\u0026rsquo;re probably launching the wrong Windows Server 2025 AMI. What actually surprised me # Honestly? How much I still remembered.\nI opened DNS Manager and found the forwarders in three clicks, before I\u0026rsquo;d consciously thought about where they were. ADSI Edit, same story: the naming contexts, the attributes, where to look and what not to touch. Ten years, and my hands still knew the way.\nThe only real work had nothing to do with Windows Server 2025: missing documentation. Not a mess, just knowledge that lived in people\u0026rsquo;s heads instead of on paper. Before you change something, you first have to rediscover why it is the way it is. I wrote about exactly that in Get on the bike!\nThe hard part isn\u0026rsquo;t the new version # In a lot of environments, the hard part is everything around it. Old trusts nobody remembers creating. Service accounts with passwords that never expire. GPOs that contradict each other. Applications still binding with plain LDAP or falling back to NTLM. And above all: documentation that doesn\u0026rsquo;t exist, so nobody knows which of those things still matter. None of that shows up until the new defaults start saying no.\nThat\u0026rsquo;s why I\u0026rsquo;d always audit before you enforce:\nFix the basics first. DNS and replication health (dcdiag, repadmin /replsummary) before anything else. Raise the floor. Functional levels to at least 2016, SYSVOL on DFSR. Find out who still talks the old way. LDAP channel binding audit events (3074 and 3075), NTLM auditing, RC4 usage. Then fix the clients, not the policy. Clean up while you\u0026rsquo;re there. Old GPOs, stale trusts, service accounts that should be gMSAs or dMSAs. Script all of it. Every step in PowerShell, in a repository. The migration you can repeat is the migration you can trust, and the scripts are the documentation the next person will thank you for. Fundamentals don\u0026rsquo;t expire # Ten years is a long time in IT. Frameworks come and go, clouds rename their services every year, and half of what was \u0026ldquo;best practice\u0026rdquo; in 2016 is now an anti-pattern.\nBut Kerberos, LDAP, DNS and replication didn\u0026rsquo;t care. If you invest in understanding how things work underneath, that knowledge keeps paying off, even when you come back a decade later.\nPicking up something you used to know? Start with the fundamentals. They\u0026rsquo;re probably still there, waiting for you.\n","date":"October 9, 2026","externalUrl":null,"permalink":"/articles/back-to-active-directory/","section":"Articles","summary":"Ten years ago I moved from Windows to Linux, and later to the cloud. Recently I picked up Active Directory again, to migrate an environment to Windows Server 2025.\nI expected to spend weeks relearning. Instead, it felt like I’d never left.\n","title":"Back to Active Directory after 10 years: what changed, and what didn't","type":"articles"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/devsecops/","section":"Tags","summary":"","title":"DevSecOps","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/gitops/","section":"Tags","summary":"","title":"GitOps","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/iam/","section":"Tags","summary":"","title":"IAM","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/categories/identity/","section":"Categories","summary":"","title":"Identity","type":"categories"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/kerberos/","section":"Tags","summary":"","title":"Kerberos","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/mailcow/","section":"Tags","summary":"","title":"Mailcow","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/migration/","section":"Tags","summary":"","title":"Migration","type":"tags"},{"content":" Overview # This site, my mail server and everything around it run on a single small ARM server. It\u0026rsquo;s a personal platform, but I run it the way I\u0026rsquo;d run production at work: everything in Git, secrets out of Git, encrypted at rest, patched automatically, and no tool gets more access than it needs.\nThat last rule got a lot more interesting once I started using an AI coding agent on it.\nFinally practising what I preach # I\u0026rsquo;ve worked this way for most of my career. I started as a Microsoft engineer and moved into Linux, bootstrapping machines from code with tools like cloud-init when most teams still installed servers by hand. At a global data center provider I helped deliver the government IaaS underneath most of the Dutch government\u0026rsquo;s online services, DigiD and MijnOverheid.nl among them, on plain Linux with hand-written iptables and Snort. Then I became a cloud engineer building AWS landing zones from code, and today I build FastAPI automation for identity governance (more about me).\nEvery step had the same rule: configuration belongs in a repository. That\u0026rsquo;s not a policy for me, it\u0026rsquo;s just how I think.\nMy own server was the exception. Each service lived in its own folder under /opt, set up by hand and tweaked over time. The cobbler\u0026rsquo;s children have no shoes: I knew it should be in Git, I just never made the time. So I finally did what I wrote about in Get on the bike!, and the whole server is now one repository:\nbufon/ |-- traefik/ reverse proxy, routes, redirects |-- website/ this Hugo site (theme as a Git submodule) |-- mailcow/ only MY changes to mailcow: an override file and a patch |-- claude-sandbox/ the AI sandbox (Dockerfile, firewall, compose) `-- README.md how to rebuild the server from scratch Two copies of that repo live on the server:\nA working copy in my home directory, where I edit and commit. A deploy copy in /opt, which the containers run from. It only ever changes through git pull. That split matters. An unfinished edit, mine or the AI\u0026rsquo;s, never reaches production by accident.\nmailcow itself is deliberately not in the repo. It\u0026rsquo;s someone else\u0026rsquo;s project with its own update mechanism, so I version only my changes to it, and it stays upgradable the official way.\nSecrets never touch Git. Certificates, private keys, the mailcow config with its passwords, and API tokens are all covered by .gitignore rules. I tested those rules with git check-ignore before the first commit, not after.\nTraefik as the front door # Traefik terminates TLS for everything and gets its certificates from Let\u0026rsquo;s Encrypt. Websites register themselves through Docker labels; mailcow and redirects are defined in a watched config file.\nOne fix came almost for free. An old domain was supposed to forward to this site, but its route pointed back at Traefik\u0026rsquo;s own HTTP port. I\u0026rsquo;d known it was broken from the start, but it never bothered me enough to dig in, so it stayed on the someday list. Since April it had answered 84,000 requests, and not one of them got a page. With all the config in one repository, fixing it took minutes, and it\u0026rsquo;s now a proper permanent redirect. When fixing something is cheap, \u0026ldquo;later\u0026rdquo; stops being a reasonable answer.\nMail with real certificates # mailcow runs behind Traefik for webmail, but IMAP and SMTP don\u0026rsquo;t go through a web proxy. They were still using mailcow\u0026rsquo;s self-signed certificate, so mail apps warned about it and other mail servers couldn\u0026rsquo;t verify mine.\nThe fix is a small helper container that watches Traefik\u0026rsquo;s certificate store, copies the mail server\u0026rsquo;s Let\u0026rsquo;s Encrypt certificate into mailcow whenever it changes, and restarts the mail services. Renewals now flow through automatically: one certificate source, no manual steps.\nThe host # Full disk encryption. The server can\u0026rsquo;t boot without the passphrase, and I can unlock it remotely after a reboot. Automated patching. A scheduled job pulls new images and recreates the containers; OS packages update on their own schedule. Encrypted off-site backups. Mail backups are encrypted client-side before they leave the server. Monitoring. External uptime checks, and Slack messages for finished backups, available mailcow updates, new vulnerabilities and pull requests waiting for me (more below). Security posture: measured, not assumed # \u0026ldquo;Patched regularly\u0026rdquo; sounds safe, but it only describes what I do. It doesn\u0026rsquo;t tell me what\u0026rsquo;s still exposed. So I added vulnerability scanning with Trivy, Aqua Security\u0026rsquo;s open-source scanner. No licence, no agent on the host, just a container that reads images.\nWhere What runs When it speaks up Every pull request that changes how something is built or run Trivy checks Dockerfiles and compose files for misconfigurations and the repo for secrets, then builds the website and sandbox images and scans them A HIGH or CRITICAL misconfiguration or secret, or a CRITICAL vulnerability that already has a fix. The check fails. On a schedule Dependabot checks the GitHub Actions and base images It opens a pull request. Those never merge themselves. Daily Trivy scans the image of every running container, the mail server and the proxy included A Slack message for every new CRITICAL vulnerability with a fix, or when the scan itself fails A few choices behind that:\nOnly what I can act on. A vulnerability without a fix is something to know about. A fix that exists and isn\u0026rsquo;t deployed is my problem. The daily scan only alerts on new findings, so a quiet Slack channel means nothing changed, not that nothing was checked. The scanner is pinned. A security tool that reads every image through the Docker socket is a juicy target. It runs one exact, verified version, not whatever \u0026ldquo;latest\u0026rdquo; happens to be that day. Upstream stays upstream. I don\u0026rsquo;t build the mail server\u0026rsquo;s images, so I don\u0026rsquo;t patch them myself. The scan tells me when it\u0026rsquo;s time to update mailcow the official way. The first scan of the repo found something right away: the nginx container serving this site ran its master process as root. It now uses the official unprivileged nginx image, which runs as an ordinary user on port 8080. Traefik doesn\u0026rsquo;t care which port it forwards to, and the web server can no longer do anything as root.\nKnowing when something waits for me # Articles merge themselves once their checks pass (how that works). Everything else, from the proxy and the pipeline to Dependabot\u0026rsquo;s updates, waits for me. The problem with waiting is that it\u0026rsquo;s silent. So a small script regularly looks for pull requests that are open with all checks finished, and posts each one to Slack once, with either \u0026ldquo;all checks passed\u0026rdquo; or the list of what failed.\nAutomation that merges on its own needs a clear signal for the moments it won\u0026rsquo;t.\nA firewalled sandbox for Claude Code # I use Claude Code as a pair engineer on this platform. It\u0026rsquo;s genuinely useful: it migrated the services into the repo, found that redirect bug, and set up the mail certificates.\nBut by default an AI agent in your terminal runs as you: your files, your SSH keys, your crontab, and if your account has sudo, the whole machine. Its built-in permission prompts and sandbox mode help, but I wanted a hard boundary. So it now runs in a container:\nControl What it does One mounted folder The container only sees the project I\u0026rsquo;m working on. No home directory, no keys, no /etc. Non-root user Claude runs as an unprivileged user. sudo is allowed for exactly one thing: the firewall script at startup. No Docker socket Mounting it would mean root on the host, so it\u0026rsquo;s never mounted. Default-deny egress Outbound traffic is dropped unless it goes to the Anthropic API, GitHub or npm over HTTPS. No LAN, no SSH out The host network and outbound SSH are blocked, which is stricter than the reference setup. IPv6 blocked So the IPv4 rules can\u0026rsquo;t be bypassed. Fail closed If the firewall can\u0026rsquo;t be set up, the container stops. Claude never starts unprotected. The firewall lives in the container\u0026rsquo;s own network namespace, so it can\u0026rsquo;t affect the host. Inside the container the rules aren\u0026rsquo;t even readable without root:\nChain OUTPUT (policy DROP) ACCEPT udp dpt:53 # DNS ACCEPT all lo # localhost ACCEPT all state RELATED,ESTABLISHED ACCEPT tcp dpt:443 match-set allowed-domains dst # Anthropic, GitHub, npm REJECT all reject-with icmp-admin-prohibited A blocked site fails instantly instead of hanging, so it\u0026rsquo;s obvious when the firewall is what stopped something. Day to day it\u0026rsquo;s just a few aliases: cs opens Claude in the sandbox, csc continues the last conversation, and csh gives me a shell inside it.\nThe honest caveat: the project folder is shared. Anything Claude writes there, I could run on the host myself. The sandbox protects the machine from the agent, not from me running code I haven\u0026rsquo;t read. Review first, then run.\nWhat I took away from this # Put it in Git, then look at it. Real bugs showed up the moment the config was in one place and readable. Least privilege applies to AI too. An agent is a very fast, very capable user. Give it the access you\u0026rsquo;d give a new contractor on day one, not your own. Measure, don\u0026rsquo;t assume. A patch run is a process. A scan of what\u0026rsquo;s actually running is evidence. Fail closed. A security control that silently doesn\u0026rsquo;t start is worse than none, because you think you\u0026rsquo;re protected. Keep upstream upstream. Version your changes to someone else\u0026rsquo;s software, not the software itself. ","date":"October 9, 2026","externalUrl":null,"permalink":"/projects/self-hosted-platform/","section":"Projects","summary":"How I run my website, reverse proxy and mail server from a single Git repository, and how I let Claude Code work on it from inside a locked-down container.","title":"One Repo, One Server: My Self-Hosted Platform and a Firewalled AI Sandbox","type":"projects"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/security/","section":"Tags","summary":"","title":"Security","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/traefik/","section":"Tags","summary":"","title":"Traefik","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/trivy/","section":"Tags","summary":"","title":"Trivy","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/vulnerability-management/","section":"Tags","summary":"","title":"Vulnerability-Management","type":"tags"},{"content":"","date":"October 9, 2026","externalUrl":null,"permalink":"/tags/windowsserver/","section":"Tags","summary":"","title":"WindowsServer","type":"tags"},{"content":"","date":"October 6, 2026","externalUrl":null,"permalink":"/tags/aws/","section":"Tags","summary":"","title":"AWS","type":"tags"},{"content":"","date":"October 6, 2026","externalUrl":null,"permalink":"/categories/cloud-security/","section":"Categories","summary":"","title":"Cloud Security","type":"categories"},{"content":"","date":"October 6, 2026","externalUrl":null,"permalink":"/tags/cloudsecurity/","section":"Tags","summary":"","title":"CloudSecurity","type":"tags"},{"content":"","date":"October 6, 2026","externalUrl":null,"permalink":"/tags/infrastructureascode/","section":"Tags","summary":"","title":"InfrastructureAsCode","type":"tags"},{"content":"","date":"October 6, 2026","externalUrl":null,"permalink":"/tags/secureboot/","section":"Tags","summary":"","title":"SecureBoot","type":"tags"},{"content":"","date":"October 6, 2026","externalUrl":null,"permalink":"/tags/terraform/","section":"Tags","summary":"","title":"Terraform","type":"tags"},{"content":"Recently I ran into this on a fresh Windows Server 2025 instance on EC2:\n\u0026ldquo;The Secure Boot update failed to update KEK 2023 with error Secure Boot is not enabled on this machine.\u0026rdquo;\nThe instance was UEFI, it was Windows Server 2025, and it came from an official Amazon AMI. So why wasn\u0026rsquo;t Secure Boot on?\nUEFI is not Secure Boot # The standard Windows_Server-2025-English-Full-Base AMI boots in UEFI mode by default. A lot of engineers stop there, because UEFI is the prerequisite for Secure Boot, so it feels like the box is ticked.\nIt isn\u0026rsquo;t. UEFI is the firmware mode. Secure Boot needs the right keys in the UEFI variable store, and the standard AMI doesn\u0026rsquo;t ship with them enabled.\nAWS publishes a separate family for that: TPM-Windows_Server-*. These images come with NitroTPM enabled, the TPM 2.0 driver installed, and UEFI Secure Boot enabled with Microsoft keys. They cost the same.\nWhy Secure Boot should be your minimum # Secure Boot makes sure that only signed, trusted code runs before Windows starts: the firmware, the boot manager, and the OS loader. That closes off the layer where bootkits and rootkits like to live. Malware that loads before the OS can hide from antivirus and EDR, survive reinstalls of applications, and tamper with the kernel before any of your security tooling is running. Without Secure Boot, nothing checks that chain.\nIt\u0026rsquo;s also the foundation for other controls. With NitroTPM and Secure Boot, BitLocker can seal its keys to a measured, trusted boot state, and measured boot gives you something to attest against. Most hardening baselines, including the CIS benchmarks and Microsoft\u0026rsquo;s own security baselines, assume Secure Boot is enabled. If it isn\u0026rsquo;t, you start every audit with an exception.\nRunning without it costs you nothing visible today. That\u0026rsquo;s exactly the problem: the instance boots fine, so nobody notices.\nWhy so many teams pick the wrong one # The naming doesn\u0026rsquo;t help. The differentiator is a prefix (TPM-) instead of a suffix, so you only find it if you already know it exists. The Quick Start in the console, most tutorials, and most Terraform examples online use the plain name, so it gets copied from project to project. And because nothing breaks, the choice is never questioned.\nAnd once it\u0026rsquo;s on, keep it current # Secure Boot is only as strong as its certificates and revocation lists. Microsoft is replacing the 2011 Secure Boot certificates, which began expiring in June 2026, with 2023 versions. Machines that don\u0026rsquo;t get the new certificates keep booting, but they no longer receive updates to the boot chain, including revocations for known-vulnerable boot managers.\nOn AWS, TPM AMIs dated 2026.02.11 or later already include the new certificates. Older instances can be updated with AWS\u0026rsquo;s Update-EC2SecureBootCertificate.ps1 script.\nFix it once, in code # Changing the filter is the easy part:\nfilter { name = \u0026#34;name\u0026#34; values = [\u0026#34;TPM-Windows_Server-2025-English-Full-Base-*\u0026#34;] } The better fix is making the wrong choice impossible. Add a postcondition to the data source in your shared module:\nlifecycle { postcondition { condition = self.tpm_support == \u0026#34;v2.0\u0026#34; \u0026amp;\u0026amp; self.boot_mode == \u0026#34;uefi\u0026#34; error_message = \u0026#34;AMI ${self.name} has no NitroTPM/Secure Boot. Use a TPM-Windows_Server-* image.\u0026#34; } } Now terraform plan fails before anyone deploys an unprotected VM. That beats a wiki page nobody reads.\nWatch your golden images # If you bake your own AMIs with Packer or Image Builder, start from a TPM- base and make sure the UEFI data survives the copy. One golden image built from the wrong base can quietly spread across your whole estate for years.\nExceptions # Bare metal instance types don\u0026rsquo;t support UEFI Secure Boot, and Windows VMs imported with VM Import can\u0026rsquo;t be imported with Secure Boot. For everything else in an enterprise environment, Secure Boot should be the baseline, and the TPM AMI is the easiest way to get there.\nCheck your Terraform today. If your filter starts with Windows_Server-2025, you know what to do.\n","date":"October 6, 2026","externalUrl":null,"permalink":"/articles/wrong-windows-server-2025-ami/","section":"Articles","summary":"Recently I ran into this on a fresh Windows Server 2025 instance on EC2:\n“The Secure Boot update failed to update KEK 2023 with error Secure Boot is not enabled on this machine.”\nThe instance was UEFI, it was Windows Server 2025, and it came from an official Amazon AMI. So why wasn’t Secure Boot on?\n","title":"You're probably launching the wrong Windows Server 2025 AMI on AWS","type":"articles"},{"content":"","date":"October 1, 2026","externalUrl":null,"permalink":"/tags/ansible/","section":"Tags","summary":"","title":"Ansible","type":"tags"},{"content":"","date":"October 1, 2026","externalUrl":null,"permalink":"/tags/clickops/","section":"Tags","summary":"","title":"ClickOps","type":"tags"},{"content":"","date":"October 1, 2026","externalUrl":null,"permalink":"/tags/documentation/","section":"Tags","summary":"","title":"Documentation","type":"tags"},{"content":"You probably know a team like this.\nServers get built by hand. Settings get clicked through one machine at a time. Changes on critical systems are done in an RDP session, and GPOs, templates and Ansible are nowhere to be seen. Every server is a little different, and only the person who built it knows why.\nIt\u0026rsquo;s ClickOps, and it\u0026rsquo;s still everywhere, especially in teams that have \u0026ldquo;always done it this way.\u0026rdquo;\nTo me it looks like a team running beside their bike, sweating and out of breath, when they could just get on it.\n\u0026ldquo;We don\u0026rsquo;t have time\u0026rdquo; # Ask why they don\u0026rsquo;t automate, and nine times out of ten you get the same answer: no time.\nThat excuse deserves a closer look, because the time is already being spent, just not in a way anyone notices:\nBuilding the same server by hand for the tenth time. Troubleshooting why server A behaves differently from server B. Finding out how something was configured, because nobody wrote it down. Onboarding a new colleague who has to learn every quirk by heart. \u0026ldquo;No time\u0026rdquo; really means \u0026ldquo;no time now, so we\u0026rsquo;ll keep paying for it later.\u0026rdquo;\nThat\u0026rsquo;s the trap. Manual work creates the workload that keeps you too busy to automate it.\nWhat\u0026rsquo;s really behind it # \u0026ldquo;No time\u0026rdquo; is usually a cover for something else:\n\u0026ldquo;It works, don\u0026rsquo;t touch it.\u0026rdquo; Nobody wants to be the one who breaks something, so nobody improves anything. Knowledge stays in people\u0026rsquo;s heads. Sometimes on purpose. If you\u0026rsquo;re the only one who knows how it works, you feel needed. Comfort. Clicking feels safe. A playbook or a GPO means learning something new. Management sees the cost, not the payoff. A week of writing automation shows up on a timesheet. Hundreds of hours of manual work spread over a year don\u0026rsquo;t. The biggest problem: documentation # Every ClickOps environment I\u0026rsquo;ve walked into had the same gap. The documentation was missing, outdated or wrong. There\u0026rsquo;s a wiki page from 2019, a Word file on someone\u0026rsquo;s desktop and a server nobody dares to reboot.\nHere\u0026rsquo;s what I think people miss: infrastructure as code is documentation.\nA GPO tells you what the policy is. An Ansible playbook tells you how a server was built. A Git history tells you who changed what, when and why. You don\u0026rsquo;t need a separate document that goes stale the moment someone clicks something.\nWith ClickOps, the only record of how your environment was built is in someone\u0026rsquo;s memory, and that person will leave eventually. On critical systems, that isn\u0026rsquo;t only inefficient. It\u0026rsquo;s a security risk.\nDon\u0026rsquo;t build the Ferrari before the roads # There\u0026rsquo;s another side to this. Some teams finally decide to automate and go straight for the big picture: a full platform, a complete pipeline, everything in code on day one. Six months later nothing is finished, and everyone goes back to clicking.\nThey built the Ferrari without building the roads.\nRome wasn\u0026rsquo;t built in a day either. My approach is simple:\nDo it by hand once, and write down what you did. The second time, script it. It doesn\u0026rsquo;t need to be pretty. Semi-automated is already a huge win. When it annoys you again, automate it fully. A playbook, a GPO or a pipeline, in Git. Invest once, then keep building on it. Every small script is a piece of road. Before long you have a network, and then the Ferrari actually has somewhere to go.\nThe bottom line # Doing everything by hand isn\u0026rsquo;t a badge of honor. It\u0026rsquo;s a risk, it\u0026rsquo;s expensive, and in the end it\u0026rsquo;s just a lot of pointless work. You don\u0026rsquo;t need a big bang either. Start with one script.\nStop running beside the bike. Get on it!\nWhat\u0026rsquo;s the real reason in your team? Is it really time, or something else? Let me know in the comments.\n","date":"October 1, 2026","externalUrl":null,"permalink":"/articles/get-on-the-bike/","section":"Articles","summary":"You probably know a team like this.\nServers get built by hand. Settings get clicked through one machine at a time. Changes on critical systems are done in an RDP session, and GPOs, templates and Ansible are nowhere to be seen. Every server is a little different, and only the person who built it knows why.\nIt’s ClickOps, and it’s still everywhere, especially in teams that have “always done it this way.”\nTo me it looks like a team running beside their bike, sweating and out of breath, when they could just get on it.\n","title":"Get on the bike! Why ClickOps teams keep running beside their bike","type":"articles"},{"content":"","date":"October 1, 2026","externalUrl":null,"permalink":"/tags/gpo/","section":"Tags","summary":"","title":"GPO","type":"tags"},{"content":"","date":"October 1, 2026","externalUrl":null,"permalink":"/tags/infrastructure-as-code/","section":"Tags","summary":"","title":"Infrastructure as Code","type":"tags"},{"content":"","date":"August 25, 2026","externalUrl":null,"permalink":"/tags/azure/","section":"Tags","summary":"","title":"Azure","type":"tags"},{"content":"","date":"August 25, 2026","externalUrl":null,"permalink":"/categories/cloud-architecture/","section":"Categories","summary":"","title":"Cloud Architecture","type":"categories"},{"content":"","date":"August 25, 2026","externalUrl":null,"permalink":"/tags/cloudarchitecture/","section":"Tags","summary":"","title":"CloudArchitecture","type":"tags"},{"content":"","date":"August 25, 2026","externalUrl":null,"permalink":"/tags/cloudgovernance/","section":"Tags","summary":"","title":"CloudGovernance","type":"tags"},{"content":"","date":"August 25, 2026","externalUrl":null,"permalink":"/tags/identitymanagement/","section":"Tags","summary":"","title":"IdentityManagement","type":"tags"},{"content":"Storing a JSON object inside a single tag looks clever on paper. It falls apart the moment you actually try to filter on it.\nA pattern I keep running into: teams cram all their metadata into one JSON object and store that as a single tag value. Feels structured. Looks tidy. Problem is, the system reading your tags doesn\u0026rsquo;t agree.\nWhether you\u0026rsquo;re tagging a resource, a resource group, or an app registration, once you write your metadata as one text blob, the query engine underneath doesn\u0026rsquo;t see fields anymore. It just sees a string. That\u0026rsquo;s it.\nYou usually don\u0026rsquo;t notice until you try to search for something.\nThe scenario # I ran into this with a basic scenario: find every application tagged with a specific identifier. With a JSON blob as your tag, you\u0026rsquo;re stuck with two bad options. Either match the whole string exactly, which breaks the second someone reorders the keys or adds a stray space, or you search with contains().\ncontains() is where it really falls apart. That identifier ends up matching all over the place:\nas part of a completely different, longer value buried inside a free text field like notes as random text that just happens to share the same digits So you end up with a results list full of noise. Rows that have nothing to do with what you\u0026rsquo;re looking for, just because a number happened to show up somewhere in the string. The query has no way of knowing whether that value actually belongs to the key you care about. It only knows the text is in there somewhere.\nThe fix # The fix is honestly a bit boring: split your metadata into separate, individual key:value tags instead of nesting everything inside a JSON string.\nDo this:\n[\u0026#34;earthcode:1234455666\u0026#34;, \u0026#34;environment:production\u0026#34;] Not this:\n[\u0026#34;{\u0026#34;earthcode\u0026#34;:\u0026#34;1234455666\u0026#34;,\u0026#34;environment\u0026#34;:\u0026#34;production\u0026#34;}\u0026#34;] Why it\u0026rsquo;s worth the extra effort # Exact matches. You\u0026rsquo;re comparing the full tag value, not guessing based on a substring. Filters you can combine. Search by earthcode and environment together, without pulling in noise. Nothing breaks from formatting. Key order, spacing, capitalization inside a JSON string used to be able to quietly break your filter. With flat tags, none of that matters anymore. Cheaper and faster. Filtering on an exact tag server-side beats pulling everything back and parsing it locally every time. Actually scales. Once you\u0026rsquo;re managing hundreds or thousands of resources, this is the difference between a query that works and one you have to babysit. The exact format varies by platform, sometimes it\u0026rsquo;s a dictionary, sometimes a list of strings. Doesn\u0026rsquo;t really matter which. The idea stays the same: keep your tags flat, keep them separate, one value per key.\nA tag doesn\u0026rsquo;t need to look clever. It needs to be something your system can actually read.\n","date":"August 25, 2026","externalUrl":null,"permalink":"/articles/flat_tags_vs_json_blob_tags/","section":"Articles","summary":"Why cramming metadata into one JSON blob tag breaks filtering, and why flat key:value tags scale better.","title":"Storing a JSON Object in a Single Tag Looks Clever on Paper","type":"articles"},{"content":"","date":"August 25, 2026","externalUrl":null,"permalink":"/tags/tagging/","section":"Tags","summary":"","title":"Tagging","type":"tags"},{"content":" Overview # My mail runs on mailcow, a Docker-based mail stack, on the same small server as this website. Both sit behind Traefik. Sharing one server with a mail stack that expects to own it takes a few deliberate choices: who gets the web ports, where certificates come from, and how to change upstream software without losing the ability to update it.\nThis page is about those choices. For the server as a whole, including full disk encryption, automated patching, encrypted off-site backups, vulnerability scanning and the sandbox I use for AI-assisted work, see One Repo, One Server.\nOne front door for the web, direct ports for mail # Mailcow ships with its own nginx that wants ports 80 and 443. On a shared server that\u0026rsquo;s one owner too many, so Traefik owns the web ports and mailcow\u0026rsquo;s nginx doesn\u0026rsquo;t publish any:\nWeb traffic (webmail, ActiveSync, autodiscover) goes through Traefik. Mail traffic (SMTP on 25/465/587, IMAP on 993) goes straight to postfix and dovecot, because those protocols don\u0026rsquo;t belong behind a web proxy. ActiveSync needs one extra trick. Phones call /Microsoft-Server-ActiveSync, but SOGo serves it under /SOGo/, so Traefik routes it to SOGo directly and rewrites the path:\nhttp: routers: sogo-eas: rule: \u0026#34;Host(`mail.bufon.nl`) \u0026amp;\u0026amp; PathPrefix(`/Microsoft-Server-ActiveSync`)\u0026#34; priority: 1000 entryPoints: [websecure] tls: certResolver: le service: sogo-eas middlewares: [sogo-eas-rewrite] middlewares: sogo-eas-rewrite: replacePathRegex: regex: \u0026#34;^/Microsoft-Server-ActiveSync(.*)\u0026#34; replacement: \u0026#34;/SOGo/Microsoft-Server-ActiveSync$1\u0026#34; One certificate source for web and mail # Because Traefik handles the web side, mailcow\u0026rsquo;s own Let\u0026rsquo;s Encrypt client is switched off. That left IMAP and SMTP on a self-signed certificate: mail apps warned about it, and other mail servers couldn\u0026rsquo;t verify mine.\nThe fix is a small sidecar that watches Traefik\u0026rsquo;s certificate store, copies the mail.bufon.nl certificate into mailcow, and restarts the mail services whenever it changes. It lives in a docker-compose.override.yml, so mailcow\u0026rsquo;s own files stay untouched:\nservices: certdumper: image: ghcr.io/kereis/traefik-certs-dumper:1.8.28 command: --restart-containers ${COMPOSE_PROJECT_NAME}-postfix-mailcow-1,${COMPOSE_PROJECT_NAME}-dovecot-mailcow-1,${COMPOSE_PROJECT_NAME}-nginx-mailcow-1 network_mode: none restart: always volumes: - /opt/bufon/traefik/acme:/traefik:ro - ./data/assets/ssl:/output:rw - /var/run/docker.sock:/var/run/docker.sock:ro environment: - DOMAIN=${MAILCOW_HOSTNAME} Renewals now flow through on their own: Traefik renews, the sidecar notices, and the mail services pick up the new certificate within a minute.\nThe trade-off. To restart the mail containers, the sidecar needs the Docker socket. The :ro on that mount only makes the socket file read-only; anything that can talk to the Docker API can still start a privileged container, which means root on the host. So this small helper is effectively root. I accept that because it has no network at all (network_mode: none) and only does one thing. It also runs a pinned version: mailcow\u0026rsquo;s update.sh pulls every image in the compose project, the override included, so with latest each mail update would quietly bring a new certdumper too. Now a new release arrives as a Dependabot pull request, and nothing with root-equivalent access on my server changes without my review.\nThe website behind the same front door # The site joins Traefik\u0026rsquo;s network like any other service and describes its own routing with labels. It\u0026rsquo;s a production build: Hugo turns the content into static files at build time and an unprivileged nginx serves them on port 8080, so nothing in the container runs as root and there\u0026rsquo;s no development server on the internet.\nname: bufon services: web: build: . image: bufon-website container_name: bufon restart: always labels: - \u0026#34;traefik.enable=true\u0026#34; # Only bufon.nl; www.bufon.nl redirects to it - \u0026#34;traefik.http.routers.hugo.rule=Host(`bufon.nl`)\u0026#34; - \u0026#34;traefik.http.routers.hugo.entrypoints=websecure\u0026#34; - \u0026#34;traefik.http.routers.hugo.tls.certresolver=le\u0026#34; - \u0026#34;traefik.http.routers.hugo.tls.domains[0].main=bufon.nl\u0026#34; - \u0026#34;traefik.http.routers.hugo.tls.domains[0].sans=www.bufon.nl\u0026#34; - \u0026#34;traefik.http.routers.hugo.middlewares=bufon-headers@file\u0026#34; - \u0026#34;traefik.http.services.hugo.loadbalancer.server.port=8080\u0026#34; networks: - traefik_proxy networks: traefik_proxy: external: true The bufon-headers middleware adds HSTS, a Content Security Policy that blocks framing, and the other usual security headers, so every response gets them without the site having to know.\nStaying upgradable # Mailcow updates itself with its own update.sh, which expects a clean checkout. So I never fork it. My changes live in my own repository instead: the override file above, plus a small patch for the few upstream files I had to touch. After an update, the patch re-applies with one git apply, and anything that conflicts shows up immediately instead of being silently overwritten.\n","date":"August 24, 2026","externalUrl":null,"permalink":"/projects/encrypted-mail-platform/","section":"Projects","summary":"How mailcow and a Hugo site share one server: Traefik for the web, direct ports for mail, one certificate source and an upgrade path that never forks upstream.","title":"Encrypted Mail Platform Setup","type":"projects"},{"content":"","date":"August 24, 2026","externalUrl":null,"permalink":"/tags/sre/","section":"Tags","summary":"","title":"SRE","type":"tags"},{"content":"","date":"August 24, 2026","externalUrl":null,"permalink":"/tags/tls/","section":"Tags","summary":"","title":"Tls","type":"tags"},{"content":"If you’re looking for someone who quietly follows outdated runbooks and never questions a design decision, you probably shouldn’t hire me.\nSince 2009 I’ve built and secured infrastructure across Windows, Linux, cloud and identity. That can look scattered. It isn\u0026rsquo;t: the platform changes, the job doesn\u0026rsquo;t. Whether it\u0026rsquo;s a domain controller, a fleet of more than a thousand Linux servers or an AWS organization, I make it secure by default and put it in code. And if the tool I need doesn\u0026rsquo;t exist, I build it, these days mostly in Python and FastAPI.\nHow I got here # It started when I was a kid, with MS-DOS: editing AUTOEXEC.BAT and CONFIG.SYS, loading HIMEM.SYS just right to free up enough memory for the next game. I still remember those lines. I\u0026rsquo;ve wanted to know how things work underneath ever since.\nProfessionally, I started out in 2009 as a Microsoft engineer: Windows Server, Active Directory, Group Policy, Exchange and Citrix, plus the network and the service desk. Even then I automated what I could: new Windows machines were installed with MDT task sequences, not cloned from a Ghost image.\nAt an international engineering manufacturer I ran the server park for years, mostly on my own. I set up SCCM, managed Exchange, and did just about every migration it needed: Active Directory, file servers, and the disaster recovery environment. The biggest one: moving the Hyper-V clusters from Windows Server 2008 to 2012, network stack included. In most companies that\u0026rsquo;s a project for a team or an outside partner. There, it was me. That\u0026rsquo;s where I learned that identity is the backbone of every environment, and that a well-designed policy beats a hundred manual fixes.\nThen the company centralized its IT. I wanted to keep being challenged, so I moved to a global data center provider, and into Linux, where automation became the whole job. I never looked back. I was building platforms before \u0026ldquo;cloud\u0026rdquo; was the default: machines that configured themselves from code with tools like cloud-init, when most teams still installed servers by hand and kept the steps in their heads.\nPuppet was my first real automation platform, and the one that taught me to think in desired state. Later came Ansible, which I\u0026rsquo;ve used extensively ever since.\nAt that data center provider I managed more than a thousand servers with Puppet and Ansible, and helped deliver the infrastructure (IaaS) for the Dutch government: the platform underneath most of its online public services, from DigiD and MijnOverheid.nl to many more. Mail platforms, DNS, routing, firewalling, intrusion detection: almost all of it built on plain Linux services. Redundant ring networks, BGP, keepalived moving virtual IP addresses to a healthy node when one failed, firewalls and SNAT/DNAT in hand-written iptables rules, managed in Puppet manifests of course, Snort watching the traffic. No fancy appliances with a web GUI. When millions of citizens depend on it, you learn to understand every layer yourself.\nNext, at cloud and hosting providers, I worked on critical infrastructure for large customers, including major insurers. There too we ran a fleet of more than a thousand servers on Puppet. I didn\u0026rsquo;t just write manifests; I also took Puppet itself through the major upgrades from version 4 to 7 across that fleet, the kind of job that breaks in a hundred small places before it works. I love that kind of complexity. I\u0026rsquo;ve touched and tuned almost every layer of those platforms, from the operating system to Galera, MariaDB and PostgreSQL clusters that simply weren\u0026rsquo;t allowed to go down.\nI didn\u0026rsquo;t just keep those layers running, I designed and built the stacks themselves, end to end. A typical one: HAProxy or Nginx as the reverse proxy and load balancer at the front, then the web and application servers, then the backend. And when that backend is a three-node MariaDB Galera cluster, the database needs routing too: a proxy in between that knows which node takes the writes and pulls a failed node out of rotation before the application notices. Every hop is a place where traffic can get lost, so I made sure I understood each one.\nUpgrades were part of that job too. After every major MariaDB upgrade I ran MySQLTuner against the cluster, because settings that were fine in one version can be deprecated or even harmful in the next. The query cache is the classic example: tuned for years, then something you turn off. And when a database did go down, I wanted evidence, not guesses: I wrote a script that captured what an RDS instance was doing just before it crashed, so a colleague could open the dump afterwards and see exactly what had happened.\nThere I also built a complete build street on Foreman, cloud-init and Puppet. A new VM went from request to fully configured, monitoring included, without anyone touching it. Zabbix picked up every new machine automatically, but only started alerting once someone ticked it as production. A server that isn\u0026rsquo;t live yet shouldn\u0026rsquo;t wake anyone up at night.\nAt a multinational in defense and aerospace I brought that automation to a more traditional environment: configuring Linux and Windows with Puppet and Ansible, automating ITSM processes around ServiceNow, and improving the CI/CD workflows of the development teams.\nFrom there I went from Linux engineer to cloud engineer. When the cloud arrived, it felt less like a revolution and more like the rest of the world catching up. At a global, publicly listed enterprise, as a senior SRE and DevSecOps engineer on AWS, that looked like this:\nLanding zone from code. A new AWS landing zone built with CDK, Python, StackSets and AWS Organizations. Identity first. An IAM matrix designed on least privilege, and IAM Identity Center single sign-on through SAML with Entra ID, with access driven by RBAC groups. Authorization that scales. A proof of concept with a Lambda authorizer for API Gateway, so one Cognito identity pool could serve every API instead of hitting the limit of ten pools. Security and cost as one problem. An event-driven Lambda that sets log retention per account or OU. It\u0026rsquo;s good hygiene, and it stopped paying for logs nobody needed. Notifications as a platform service. A microservice that routed AWS events automatically to the right Microsoft Teams channel, with the same clear formatting for every team. Quality in the pipeline. CI/CD with CodeArtifact, SonarQube for code quality and Sonatype for vulnerability scanning across languages, plus the process for triaging and fixing what they find. Linux in the cloud too. EC2 fleets, NTP clusters and ETL servers configured and patched with Ansible. I was also the team\u0026rsquo;s Scrum Master: running the ceremonies, clearing blockers and keeping the work flowing.\nToday I\u0026rsquo;ve come full circle, back to identity, but with everything I\u0026rsquo;ve learned since. Ten years after I left Windows, I picked Active Directory back up to migrate an environment to Windows Server 2025, and it felt like I\u0026rsquo;d never left. Fundamentals don\u0026rsquo;t expire. Now, at a global enterprise in the energy industry, I work on Identity Governance \u0026amp; Administration (IGA), and I build FastAPI services that let platform teams automate against our IGA solution. Identity becomes something they can call from their pipelines, not a ticket they wait on.\nIt\u0026rsquo;s the same idea as always, at a bigger scale: if it isn\u0026rsquo;t in a repository, it doesn\u0026rsquo;t exist. I\u0026rsquo;ve lived that from SVN to GitLab, GitHub and CodeCommit, with pipelines in Jenkins and everything that came after.\nThat\u0026rsquo;s still how I work today, whether it\u0026rsquo;s an enterprise landing zone or my own server, where I even let an AI agent work on it without giving it the keys to the kingdom.\nUnder the hood # I like to know what\u0026rsquo;s actually happening underneath. I\u0026rsquo;ve run FortiGates, but what really interests me is the layer below: how conntrack tracks a connection, what SNAT and DNAT do to a packet on its way through, how the ARP table gets filled and refreshed, why keepalived sends a gratuitous ARP when a virtual IP moves, and why a stale entry can make traffic vanish while every dashboard looks green.\nThat curiosity goes for most of IT, especially the services everything else quietly depends on: DNS (resolvers, forwarders, CoreDNS), NTP, mail, load balancing, databases. Ask me what happens when you type a domain name, and I\u0026rsquo;ll happily walk you from the root servers to the TLD to the authoritative nameserver and its records, in plain words.\nI\u0026rsquo;ve gone deep in each layer I\u0026rsquo;ve worked in: network, Linux, Windows and identity, databases, cloud. That\u0026rsquo;s why I see quickly how they fit together, and where a problem actually lives when every team says it isn\u0026rsquo;t theirs. I\u0026rsquo;m at my best next to specialists: I can follow them into their area, and I make sure the pieces fit and the whole team understands how.\nNot everyone believes one person can cover that much ground, and that\u0026rsquo;s fine. I\u0026rsquo;ve been turned down for identity roles because I hadn\u0026rsquo;t built Conditional Access policies yet. It\u0026rsquo;s often seen as the hard part of identity. With my background, it\u0026rsquo;s the familiar part. A Conditional Access policy is a firewall rule for identity: who is asking, from which device and location, for which app, and then allow, block or require MFA. Report-only mode is what I did with every new iptables rule: log first, enforce later. Under a new tool there\u0026rsquo;s usually something I\u0026rsquo;ve already run, so I rarely start from zero.\nThe easiest way to find out is to give me something to automate: a firewall, a landing zone, an identity workflow. I honestly don\u0026rsquo;t mind what it is.\nCareer at a glance # From Role Where Now Identity (IGA) Engineer, FastAPI automation Global energy company 2022 Senior SRE / DevSecOps Engineer (AWS), Scrum Master Global, publicly listed enterprise 2021 DevOps Application Engineer Defense and aerospace multinational 2018 System Engineer Cloud and hosting providers 2015 IT Operations Engineer Global data center provider 2011 System Administrator International engineering manufacturer 2009 Engineer IT service provider Certifications # Cloud: AWS Certified Solutions Architect – Associate (2020), AWS Certified Cloud Practitioner (2019) Linux: LPIC-2 (2017), LPIC-1 (2016) Kubernetes: Certified Kubernetes Administrator course (2025) Microsoft: MCITP Server Administrator on Windows Server 2008 (2013) and 2012 (2015), Windows 7 Configuring 70-680 (2011) Network: CCNA 1, 2 and 3 (2004–2006) Why you might not want me on your team # ❌ I don’t accept “we’ve always done it this way.”\n❌ I automate repetitive work.\n❌ I think in security even when no one asked.\nSo if you want to secure identities and break bad habits, hop on the boat or the train with me. 🚤🚆\nLet\u0026rsquo;s talk # Working on identity, automation or a platform that needs to be secure by default? Reach out on LinkedIn. I\u0026rsquo;m always up for a conversation about hard problems.\nAnd if I don\u0026rsquo;t reply right away, I\u0026rsquo;m probably out for a run.\n","externalUrl":null,"permalink":"/about/","section":"Security-driven automation, whatever it runs on.","summary":"If you’re looking for someone who quietly follows outdated runbooks and never questions a design decision, you probably shouldn’t hire me.\nSince 2009 I’ve built and secured infrastructure across Windows, Linux, cloud and identity. That can look scattered. It isn’t: the platform changes, the job doesn’t. Whether it’s a domain controller, a fleet of more than a thousand Linux servers or an AWS organization, I make it secure by default and put it in code. And if the tool I need doesn’t exist, I build it, these days mostly in Python and FastAPI.\n","title":"About Me","type":"page"}]