</>CodeWithKarani

The pull model: systemd timers and release polling

Karani GeoffreyKarani Geoffrey8 min read

Sometimes the answer is no. Not "explain it again", not "let us think about it" - a flat no from a security team that has a policy against third-party agents on production hosts, and that policy is older than your project and is not going to bend for it.

I have had this conversation twice. The first time I treated it as a failure and the client spent another six months pulling and migrating by hand. The second time I had this design ready, and we were shipping within a week. The trade is real and I will be specific about it below, but the headline is good: you can keep almost all the value of an automated deployment while removing the last thing the security team objects to, which is an external system being able to cause execution on their box.

This is the fallback in the full walkthrough for deploying to a client server you cannot touch. Reach for it when a self-hosted runner has been refused, not before, because you do give something up.

GitHub Actions is reduced to test-and-tag. A systemd timer on the client's server asks the releases API what the latest tag is, compares it with what is checked out in apps/your_app, and runs the same deploy script when they differ.

  • No agent, no inbound port, and nothing outside the building can cause code to run. The schedule belongs to the client.
  • The whole mechanism is three files: a polling script, a oneshot service, and a timer.
  • Persistent=true does nothing on a monotonic timer. Use OnBootSec= for the boot case.
  • What you lose: deploy logs you can see, immediacy, and the approval gate. Those go back to the client's side, which is either the point or the problem depending on who is asking.

What GitHub is still for

The pipeline does not disappear, it shortens. Actions still runs the tests and still produces the artefact that matters, which is a published release:

name: Test and release

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

permissions:
  contents: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: python -m pytest

  release:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Publish the release
        run: gh release create "$GITHUB_REF_NAME" --generate-notes
        env:
          GH_TOKEN: ${{ github.token }}

Note the ordering. A release is only published if the tests passed, which means "latest release" carries a meaning the client's server can rely on without knowing anything about your test suite. That is the contract between the two halves.

The loop, drawn

The polling loop across four intervals A timeline with four five-minute polling intervals. At nine o'clock, five past and ten past, the latest release and the checked-out tag are both v1.3.0, so the script exits without doing anything. At twelve minutes past, a v1.4.0 tag is pushed and the release is published. At the quarter past poll the latest release is v1.4.0 while the checkout is still v1.3.0, so the deploy runs. A note records that the delay between publishing and deploying is up to one full interval. 09:12 you push tag v1.4.0 Tests pass, the release is published. The server does not know yet. latest release v1.3.0 checked out v1.3.0 equal, exit 0 latest release v1.3.0 checked out v1.3.0 equal, exit 0 latest release v1.3.0 checked out v1.3.0 equal, exit 0 latest release v1.4.0 checked out v1.3.0 different, deploy now 09:00 09:05 09:10 09:15 The cost is visible in the gap Three minutes of waiting here, up to five in the worst case. There is no push, so nothing can be faster than the interval.
Every tick is a question the server asks. Nobody ever tells it anything.

The polling script

Owned by root, executable by frappe, so the account running it cannot rewrite it:

#!/usr/bin/env bash
# /usr/local/sbin/check-release.sh
set -euo pipefail

REPO=your-org/your_app
APP=/home/frappe/frappe-bench/apps/your_app

latest=$(curl -fsS --max-time 20 \
  -H 'Accept: application/vnd.github+json' \
  -H 'X-GitHub-Api-Version: 2022-11-28' \
  "https://api.github.com/repos/$REPO/releases/latest" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')

current=$(git -C "$APP" describe --tags --exact-match 2>/dev/null || echo "none")

if [ "$latest" = "$current" ]; then
  echo "Up to date on $current"
  exit 0
fi

echo "Release $latest available, currently on $current. Deploying."
exec /home/frappe/deploy.sh "$latest"

Four decisions in there worth defending.

curl -fsS makes a failed HTTP request an error rather than a page of HTML piped into the JSON parser. Without -f, a GitHub outage returns a 500 body that python3 then fails to parse, and the error you get names the wrong problem.

python3 parses the response rather than grep or sed. Python is already on any Frappe server because the framework runs on it, so this adds no dependency, and it will not be fooled by a release description that happens to contain the text tag_name.

git describe --tags --exact-match is the honest question: what tag is checked out right now. It fails when the working tree is not exactly on a tag, and the || echo "none" turns that into a value that will never equal a real release, so an odd state deploys rather than silently doing nothing.

And /releases/latest returns the most recent non-prerelease, non-draft release. That gives the client a rollback path that needs nothing from you: mark a bad release as a pre-release, and the previous one becomes latest again.

The service and the timer

# /etc/systemd/system/frappe-release-poll.service
[Unit]
Description=Deploy the latest your_app release if it has changed
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=frappe
Group=frappe
WorkingDirectory=/home/frappe/frappe-bench
ExecStart=/usr/local/sbin/check-release.sh
TimeoutStartSec=1800
# /etc/systemd/system/frappe-release-poll.timer
[Unit]
Description=Check every five minutes for a new your_app release

[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
RandomizedDelaySec=45
Unit=frappe-release-poll.service

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now frappe-release-poll.timer
systemctl list-timers frappe-release-poll.timer
NEXT                        LEFT     LAST                        PASSED  UNIT                        ACTIVATES
Wed 2026-07-29 09:15:22 EAT  3min 4s  Wed 2026-07-29 09:10:19 EAT 1min 55s frappe-release-poll.timer  frappe-release-poll.service

Five notes on those two files, because most copies of this pattern on the internet get at least one of them wrong.

Type=oneshot and no overlap. systemd will not start the service again while a previous run is still going. A migration that takes eleven minutes cannot be interrupted by the next tick. This is the reason to use a timer rather than cron, where you would be writing your own flock.

TimeoutStartSec=1800. A oneshot service inherits a default start timeout, and a backup with several gigabytes of attached files plus a migration can exceed it. Being killed halfway through bench migrate is the specific disaster described in what bench migrate actually does. Set it generously.

No Persistent=true. It looks right and it does nothing here. Persistent= only has an effect on timers configured with OnCalendar=, and this timer is monotonic. OnBootSec=3min is what actually covers the "server was off overnight" case, and the three minutes gives the network and supervisor time to come up first.

RandomizedDelaySec=45. Small politeness that matters once you have this on several clients' servers. Without it they all poll on the same second.

User=frappe. Same rule as everywhere else in this series. The deploy script needs one sudo rule for the supervisor restart, and that is the narrow sudoers wrapper from the runner hardening article, which applies here unchanged.

If you are weighing a timer against cron or against Frappe's own scheduler for other jobs on the same box, picking between them by failure mode rather than by habit goes through the trade-offs properly.

Then the diagnostics, which is the command to put in the handover document:

journalctl -u frappe-release-poll.service -n 100 --no-pager

Firewall posture, one more time

Firewall posture of the client server under the runner model and the pull model The same two column posture comparison used earlier in the series, redrawn for the runner model and the pull model. Under both, the inbound rules on the client server are unchanged from what the application already needed. Both use one outbound HTTPS connection to GitHub. The difference is in the final row: under the runner model a job queued in GitHub causes code to run on the server, while under the pull model nothing outside the building can cause execution, because the only trigger is a local timer. Self-hosted runner Pull model, systemd timer Inbound rules required on dev.client.local Inbound rules required on dev.client.local 443/tcp from staff, for the application itself 443/tcp from staff, for the application itself Nothing. The list is unchanged. Nothing. The list is unchanged. Outbound rules used Outbound rules used 443/tcp to github.com, held open continuously 443/tcp to api.github.com, twelve times an hour What can cause code to run on this machine What can cause code to run on this machine A job queued in GitHub, by anyone who can push to the repository A timer on this machine, owned and scheduled by the client, running one fixed script This last row is the entire argument for the pull model The first two rows are identical. The security team is not objecting to the network shape, they are objecting to that row.
Same diagram as earlier in the series, one row longer. The row that was implicit under the runner model is the one being argued about.

Rate limits, and the one credential that appears

Unauthenticated GitHub API requests are limited to 60 per hour per IP address. Polling every five minutes is 12 an hour, so one server sits comfortably inside it. Two things break that comfort. If the repository is private, the endpoint needs authentication at all. And the limit is per IP, so four benches behind one office NAT are sharing 60 requests an hour with every other tool on that network doing the same thing.

Both are solved with a fine-grained personal access token scoped to read-only contents on that one repository, which lifts the ceiling to 5,000 an hour:

install -m 600 -o frappe -g frappe /dev/null /home/frappe/.gh-release-token
# then write the token into it, and in the script:
#   -H "Authorization: Bearer $(cat /home/frappe/.gh-release-token)"

Note what that credential is, because it is the opposite of the one in the SSH deployment model. It is read-only, it is scoped to a single repository, it grants nothing on their server, it is held by the client on their own machine, and they can revoke it without telling you. A security reviewer will read it in about ten seconds and move on.

What you give up

Three things, and they are not small.

You lose the deploy log. This is the big one. With a runner you watch the output stream in the Actions tab and you know within seconds whether the migration worked. Here, the output goes to the client's journal on the client's machine, and getting it means asking someone to run journalctl and paste the result. Mitigate it: have deploy.sh post a short status to a webhook you control, so at least you know the outcome even if you cannot see the detail.

You lose immediacy. Up to a full interval between publishing and deploying, and no way to make it faster without polling harder. This is genuinely annoying during an incident when you have a fix ready and are watching a clock.

You lose the approval gate. There is no required reviewer here, because there is no job for anyone to approve. Publishing a release is the deployment decision, and it is yours alone. Some clients see that as a downgrade. Others prefer it, because the control they wanted was over execution on their hardware, and stopping the timer gives them that absolutely.

When to choose it

Choose the pull model when a runner has been refused, and only then. It is more moving parts on the client's server, less visibility for you, and slower. What it buys is a property no other option offers: nothing outside that building can cause anything to execute on that machine. The trigger is local, the schedule is local, the script is local and root-owned.

If you are weighing this against the alternatives rather than falling back to it, the decision table at the end of the full deployment walkthrough lays out all three side by side. And the framing that makes the whole conversation easier - that not holding your client's credentials is an asset rather than a limitation - applies here more strongly than anywhere else in the series, because under this model you hold nothing at all.

Frequently asked questions

Why not use cron instead of a systemd timer?
You can, and cron is fine. A timer buys you three things cron does not: systemd refuses to start a second run while the first is still going, journalctl gives you the full output of every run with timestamps, and systemctl list-timers shows the next scheduled run and how long ago the last one was. On a machine you cannot log into, that last one is the difference between diagnosing and guessing.
Does Persistent=true make the timer catch up after a reboot?
Not with this timer. Persistent= only has an effect on timers configured with OnCalendar=, and this one uses the monotonic OnBootSec and OnUnitActiveSec. Adding Persistent=true to a monotonic timer is a very common copy-paste that does nothing at all. Use OnBootSec to cover the boot case instead, which is what it is for.
Will polling the GitHub API get rate limited?
Unauthenticated requests are limited to 60 per hour per IP address. Polling every five minutes is 12 an hour, so a single server is comfortable. The limit is per IP, though, so several servers behind one office NAT share that 60 between them. If that is the situation, or if the repository is private, authenticate with a read-only token and the limit becomes 5,000 an hour.
How does the client roll back under this model?
They publish nothing and you do nothing, because the timer only acts on the latest release. To go back, mark the bad release as a pre-release or delete it in GitHub, so the previous one becomes latest again, then let the timer notice. It is slower than a runner-based rollback and it needs no access on your side, which is the whole point.
#systemd#Deployment#Frappe#CI/CD#Linux
Keep reading

Related articles