Patchouli Deployment
A secure, continuous deployment NixOS module triggered by webhooks or systemd timers to automatically update specific flake inputs.
Patchouli Deployment
URL for sources: https://codeberg.org/melvi/patchouli-deployment
Patchouli Deployment started as a side effect of me getting increasingly annoyed at how people deploy NixOS projects.
I kept seeing the same recommendations over and over again:
- put deployment scripts outside the repository
- give CI SSH access to the server
- use Docker containers for everything
- manually run update scripts
- keep mutable state on the server
- store random deployment logic in GitHub Actions
At some point I realized I hated almost every part of that workflow.
Every project I host already contains its own flake and usually even its own NixOS module, so having deployment logic live somewhere else felt completely backwards. If the entire point of declarative infrastructure is reproducibility, why would deployment itself become imperative spaghetti?
So I made Patchouli Deployment.
Not because I wanted a “cloud-native enterprise deployment platform”, but because I wanted a small thing that safely updates flake inputs and rebuilds systems automatically without exposing my VPS to the entire internet through an SSH key hidden inside CI secrets.
What It Actually Does
At its core, Patchouli Deployment is basically:
“a webhook that safely runs nixos-rebuild for specific flake inputs”
That’s it.
A Git push triggers a webhook. The webhook starts a systemd service. The service updates selected flake inputs. If the rebuild succeeds, the system switches generation. If it fails, everything rolls back automatically.
No deployment agents. No remote shell sessions. No persistent CI runners sitting on the machine. No Docker orchestration layer trying to reinvent package management.
The system already knows how to rebuild itself. I just needed a safe trigger mechanism.
The Main Problem I Wanted to Solve
The biggest issue for me was automatic flake input updates.
nixos-rebuild switch --upgrade honestly feels terrible once you move fully into flakes. Most existing solutions either don’t understand flake workflows properly or immediately become imperative the moment automation enters the picture.
People kept suggesting things like:
- cronjobs with shell scripts
- random
git pull && rebuildscripts - external deployment repositories
- Docker-based CI workers with root access
- fully imperative GitHub Actions pipelines
And every time I looked at those setups I thought:
“this is literally the opposite of why I moved to NixOS”
I wanted deployments to stay declarative all the way through.
Security Was The Second Big Reason
I really did not want CI having unrestricted SSH access to my VPS.
Yes, secrets are hidden. Yes, GitHub masks them in logs. Yes, technically you are “supposed” to trust the runner.
But the idea still felt awful.
A compromised workflow could easily exfiltrate the key through some external API before anyone notices. Even if the odds are low, giving full server access to a remote CI system for a simple rebuild operation seemed unnecessary.
So instead of pushing commands into the server, Patchouli Deployment works more like a pull-based trigger system.
The webhook itself does almost nothing:
- validate token
- map token to instance
- start predefined systemd service
It never executes arbitrary commands from requests. It ignores payload contents entirely. It doesn’t care about branch names, JSON fields, repository URLs or anything else.
The request is basically just:
“hey, run deployment instance X”
And that’s all.
The Architecture Ended Up Surprisingly Clean
The funny part is that the final design became way simpler than most deployment systems I looked at.
The flow is basically:
Git Push
↓
Webhook
↓
systemd service
↓
nix flake update
↓
nixos-rebuild switch
The webhook runs as root only because systemd interaction requires it.
The actual Git operations and lockfile updates happen under a dedicated unprivileged user. This separation became one of my favorite parts of the project because it naturally reduced how dangerous each component could become if something broke.
Everything is also isolated into separate instances, which ended up being incredibly useful later.
Different projects can have:
- different tokens
- different update schedules
- different flake inputs
- different repositories
- different deploy users
without interfering with each other.
The Dumbest Bug
One of the funniest bugs during development was accidentally creating a setup that did:
git reset --hard
every 30 seconds if flake.lock did not change.
Which becomes a very bad experience when you’re actively editing configuration files inside the repository.
I basically created a self-destructing NixOS config directory.
That was the moment I realized deployment automation becomes terrifying extremely quickly once Git operations start happening automatically.
Rollbacks Saved Me During Development
There was also a moment where the rollback logic unintentionally proved itself useful during development.
A broken rebuild happened while testing, and Patchouli Deployment automatically restored the repository state instead of leaving everything half-mutated.
That was the point where the project stopped feeling like a random webhook script and started feeling like something actually reliable.
NixOS generations already make failed rebuilds relatively safe, but restoring Git state automatically removed a whole category of annoying cleanup situations.
The Timer Is Horrible
The timer implementation honestly still looks ugly.
It works. I trust it. But visually and architecturally it still feels like:
“yeah this definitely evolved from me repeatedly patching things at 3 AM”
At some point I want to redesign parts of it properly.
But also this is one of those classic infrastructure moments where:
stable ugly code is better than unstable beautiful code
So for now it stays.
Why I Still Prefer This Over Most CD Systems
Because it matches how I already think about infrastructure.
Every project contains:
- its own flake
- its own deployment logic
- its own host modules
- its own update definitions
The server itself stays mostly static. The repository defines the machine. Deployment becomes a property of the flake instead of an external pipeline.
That’s the part I care about the most.
Patchouli Deployment is not trying to become Kubernetes.
It is not trying to become a universal CI platform.
It is not trying to manage containers across 50 nodes.
It just automates a very specific declarative NixOS workflow without introducing imperative garbage into the middle of it.
And honestly that’s exactly why I like it.
Configuration Example
Setting up multiple production deployments alongside isolated authorization webhooks is handled declaratively in your configuration.nix:
services.patchouli-deployment = {
enable = true;
# Global Webhook Daemon Configuration
webhook = {
enable = true;
host = "127.0.0.1"; # Kept local; expose via Nginx/Caddy proxy
port = 8787;
path = "/patchouli-deployment";
};
# Independent deployment targets
instances.tgautobot = {
flakePath = "/var/nixos-config";
flakeRef = "/var/nixos-config#pussy";
user = "licking";
inputs = [ "telegram-automation-bot" ];
dates = "daily"; # Fallback polling timer expression
};
# Webhook to Instance mapping
webhook.projects.tgautobot = {
tokenFile = config.age.secrets."tgautobot-cd-token".path;
instance = "tgautobot";
};
};
The CI Pipeline
Once configuring your instance token (tokenFile), you feed that same token into your project repository’s workflow block. Here is a production-ready example using GitHub Actions or a Forgejo Runner:
name: patchouli-deployment-webhook
on:
push:
branches:
- main
jobs:
notify:
runs-on: codeberg-tiny
steps:
- name: Trigger deployment
run: |
curl -fsS \
-X POST \
-H "Authorization: Bearer ${{ secrets.PATCHOULI_DEPLOYMENT_TOKEN }}" \
https://cd.zenisoft.net.ua/patchouli-deployment
Security Hardening out of the Box
The webhook runner relies on systemd’s aggressive sandboxing to enforce security policies directly inside the Linux kernel:
serviceConfig = {
Type = "simple";
User = "root";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true; # Token paths can't sit in /home or /root
PrivateTmp = true;
CapabilityBoundingSet = [ ]; # The service holds exactly ZERO kernel capabilities
MemoryDenyWriteExecute = true;
};
This ensures that even if an exploit is found in Python’s embedded HTTP parser, the attacker is locked in a read-only environment without network capabilities beyond opening internal sockets, unable to access user files or alter execution paths.
Comments
Server JSON storage