Skip to content

Patchouli Deployment

A secure, continuous deployment NixOS module triggered by webhooks or systemd timers to automatically update specific flake inputs.

Project
Date
May 22, 2026
Status
finished
Featured
Yes
NixOSNix FlakesSystemdPythonCI/CD

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 && rebuild scripts
  • 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

Other users will see it btw