Running Subroutine as a service
Every subroutine command on this page has been run, and every quoted output is what it
actually printed - with only paths and hostnames moved to the deployment described here.
That includes the refusals, which are worth meeting on a page rather than at two in the
morning, and a test fails the build if the two quoted bind refusals stop matching what the
program says. It includes the credentials: a token is quoted whole, because a reader who has
just run this needs to recognise what came back, and every one on this page was issued on a
throwaway instance that no longer exists. A test fails the build on any other whole
credential in the repository. The useradd, systemctl, nginx and Caddy fragments are ordinary system
administration, are not exercised by anything here, and are a starting point for however your
machines are already set up.
The shape is deliberately ordinary: a Python process listening on loopback, your own TLS proxy in front of it, systemd keeping it alive, PostgreSQL underneath once more than one person is using it. There is nothing to cluster and no message broker. If you have hosted a Django or a Rails application, you have already done this.
If you are not the one standing the server up, you want docs/connecting.md instead. This page is the operator's end; that one is organised by which of seven situations a person reaching an instance is in, and says what to ask you for. Sending it to whoever you issue a token to saves the conversation.
One thing that is not optional. Subroutine authenticates with bearer tokens, and a bearer token sent over plain HTTP is a compromised token - anything on the path has it, and it does not expire on being read.
serverefuses to listen beyond this machine unless you have said out loud that TLS is handled. Below is what that refusal looks like and how to satisfy it honestly.
Reading the commands here
Every command that acts on the service's database is written out in full, as five lines:
It is long, and the length is the point. It says on the line you are reading who it runs as, which configuration and data it uses, and which copy of the program - and those are the three things that decide which database you are about to change.
A bare subroutine … reads your own configuration, and that is not a failure you would
notice. It does not error: it finds whichever instance your account has, answers confidently
about that one, and looks exactly like success. The commands on this page that are bare are
bare on purpose - they are about a personal instance, or about a refusal any installation
produces - and each one says so where it appears.
No alias, no shell function, no PATH change. A second name for the program exists only in
a shell somebody has already set up, so an instruction that depends on one depends on a step the
reader can forget - and forgetting it does not fail, it addresses a different database. That is
the failure this convention exists to prevent, so it is not also the cure. Repetition is cheaper
than ambiguity here.
Contents
- Reading the commands here
- An account and an install
- First run, and what it writes
- PostgreSQL, and when to switch
- TLS, and why
serverefuses without it - The systemd unit
- A reverse proxy
- Adding the people
- Giving an agent a token
- Reaching it from your own machine
- Reaching it from an agent, with nothing installed
- Calendar feeds
- Backups
- Credentials
- Upgrading
- What the licence asks of you, which is almost nothing
An account and an install
A service account with no login and no home to speak of, and a virtualenv it does not own:
Drop the [postgres] extra if you are staying on SQLite. Python 3.11 or newer.
That installs the newest published release. To track the repository instead, name it:
Everything below works the same way, with one exception that has a section of its own - see tracking a git ref under Upgrading.
Subroutine keeps its files under the XDG directories - configuration in
$XDG_CONFIG_HOME/subroutine, the database in $XDG_DATA_HOME/subroutine, the current
context in $XDG_STATE_HOME/subroutine. The unit below points all three inside
/var/lib/subroutine, so the service does not depend on the account having a home directory
at all. That was checked with HOME=/nonexistent, which is the situation a --system account
is usually in.
First run, and what it writes
Make the state directory first. The unit below carries StateDirectory=subroutine, which
creates /var/lib/subroutine and hands it to the service account - but only when the service
first starts, and the service cannot start until init has run. So on this one occasion you
make it yourself:
StateDirectory= is content to find it already there, and that owner and mode are what it
would have set. Skip this and init stops with Cannot create the configuration directory,
because /var/lib is root-owned and a --system account cannot write in it.
Run init as the service account, with the same environment the unit will use, so that the
database and the signing key land where the service will look for them:
One line, because init is written for somebody setting up a to-do list. It has made the
database, the first workspace, an Inbox and the first user - who is this instance's
administrator - and it has written exactly one setting:
# Subroutine configuration. See 'subroutine config show'.
secret_key = "9ZetNDEWdo6Nu35ujhcOYa7baweWIi66A38HUPSLjaU"
That file is created 0600, and so is credentials.toml. The key signs pagination cursors
and nothing else - it is deliberately not mixed into stored token hashes, so rotating it
costs an in-flight page of results rather than every credential in the installation.
Everything else in config.toml you add yourself. When a value surprises you, ask where it
came from rather than guessing:
It lists every setting, not only the ones you have changed, which is how you find out what
there is to change. Flags beat the environment, the environment beats the file, the file beats the defaults, and
that column tells you which one won. Every setting can also be given as
SUBROUTINE_<NAME> in the environment - useful for database_url when the credential comes
from a secrets manager rather than from a file on disk.
The process reads its configuration once, at start. Change config.toml and restart the
service; serve does not reload. A setting that appears not to have taken effect is nearly
always this.
Every setting, and what it does
config show lists these; this says what they are for. A test fails the build if the two
disagree, so a setting that exists and is not here cannot ship.
| Setting | Default | What it does |
|---|---|---|
database_url |
SQLite under $XDG_DATA_HOME |
Which database to use. The one setting most installations change |
host |
127.0.0.1 |
What serve binds to. Never 0.0.0.0 by default - see TLS below |
port |
8471 |
What serve binds to |
public_url |
unset | The address a proxy serves this instance on. Published in /v1/meta, and what makes a non-loopback bind legal |
secret_key |
written by init |
Signs pagination cursors, and only that. Not mixed into token hashes, so rotating it costs an in-flight page rather than every credential |
source_url |
this project | Where this instance's source can be had. A promise the product makes, not a licence obligation |
backup_directory |
beside the database | Where db backup writes. A network volume is the intended destination |
releases |
not set | A table. [releases] with check = true lets this instance ask which versions have been released, at most once a day and only while somebody signed in is using it - never on an idle instance, and never because a calendar app polled a feed. Asking fetches the project's published list of releases, which tells whoever serves that file that an instance exists at this address, so it is off unless you turn it on. What it finds is said in this instance's log, to its administrators in the browser, and by subroutine whoami - see Upgrading |
backup_keep_upgrades |
3 |
How many pre-upgrade rollback points survive. Counts those alone - never your routine backups, which go only when db backup --keep N asks |
protected |
false |
Marks an instance whose data is real, so db restore, upgrade and profile destroy refuse without --yes |
default_connection |
local |
Which instance a write goes to when the command did not say |
local_user |
unset | Which account to act as when the database holds more than one and nobody logged in |
default_timezone |
the machine's | The last word in the timezone chain, when no user, workspace or instance says |
rate_limit |
on when reachable | Whether to limit requests. Unset means "on unless this is loopback-only and has no public_url" |
rate_limit_per_minute |
600 |
Requests per credential per minute, once limiting is on |
rate_limit_failures_per_minute |
30 |
Failed authentications per address per minute. Keyed on the address on purpose: a token prefix is the caller's to choose, so keying failures on it would hand an attacker a fresh allowance per guess |
rate_limit_polls_per_minute |
60 |
Calendar fetches per feed per minute. Its own bucket rather than a share of the one above: a poller and a person make requests at different rates, and one misconfigured calendar client should not empty the allowance its owner's terminal draws from |
calendars_enabled |
true |
Whether this instance has calendar feeds at all. A feed URL is a bearer credential that ends up in a phone's settings and possibly in a screenshot, and a leak is undetectable from here - turn it off and every address answers 404 exactly as an address naming nothing does, and no new feed can be minted. Listing and revoking keep working, so an operator who turned it off because something leaked can still end the subscription |
request_timeout_seconds |
30 |
How long the database work behind one request may spend on any single statement before it is given up on. A backstop rather than a budget: a request that reaches it has met something the design does not account for, and a bound is what turns a hang into a message somebody can act on. PostgreSQL only - see below |
max_body_bytes |
10485760 |
The largest request body this instance will read. A backstop against the request nobody meant to send, not a policy - every field has its own limit, and ten megabytes is far more than any legitimate write |
trusted_proxies |
[] |
Addresses whose X-Forwarded-For is believed. Empty ignores the header entirely, which is the safe default behind no proxy |
cors_origins |
[] |
Other origins a browser may call this API from - and act as a signed-in reader from. Empty is right for almost everyone, including you: the web UI is served by this instance, so it needs no entry here. See below before adding one |
log_level |
INFO |
How much serve logs |
dev_mode |
false |
Development only. Makes a signing key up each time the instance starts when secret_key is unset, so a throwaway instance starts without one - and its cursors do not survive a restart. Refused at start beside a public_url. Never set it on anything real |
default_page_size |
50 |
Rows a listing returns when the caller does not say |
max_page_size |
200 |
The largest single response. A client asking for more is answered across as many responses as it takes, and is told when a page it was handed is not everything - so this bounds a body rather than a call |
max_hierarchy_depth |
10 |
How deep a project or subtask tree may nest. Bounds path length and the cost of a move |
claim_lease_minutes |
30 |
How long a task claim lasts before it expires. A lease rather than a lock, so a worker that dies does not strand the work |
search_backend |
like |
Which implementation answers a search. native uses a full-text index and is PostgreSQL only - see below |
search_backend changes what a search finds, not only how fast it finds it, which is why
it is off by default and why it is worth a paragraph rather than a row.
like is what every instance has had until now: it matches your words anywhere inside the
text, so ursor finds cursor. It cannot be served by an index, so it reads every row's
prose - fine for a personal backlog and increasingly not fine as one grows.
native builds a full-text index. Measured at 20,000 tasks, a search matching nothing goes
from 119 ms to 1 ms. In exchange it matches whole words rather than fragments: seed now
finds seeded and seeding, curs still finds cursor because a word can be completed from
the start, and ursor finds nothing at all.
And a very common word stops narrowing. PostgreSQL's text search drops the, of, and
and their kind before the query is built, so cursor the finds whatever cursor finds, where
like requires both and would find nothing. That is inherent to full-text search rather than
a choice made here - it is written down because the rest of this product promises that every
word you type must appear, and under this backend that is one word short of true.
It also changes the order, and for a growing backlog that is the larger of the two gains.
native ranks: the best match comes first, and a hit in a title outranks one in a body. like
has no relevance to rank by, so its results fall back to the listing's ordinary order - newest
first unless you asked for something else. Measured on a real workspace, the same query found
the same 28 rows under both and put entirely different ones at the top.
It exists on PostgreSQL only. Asking for it on SQLite is not an error - you get like, and
GET /v1/meta reports which one is actually answering. Turning it on needs no migration
beyond the ordinary subroutine db upgrade; turning it off again is a configuration change
and nothing else.
request_timeout_seconds is what stops an outage being a silence. Nothing bounded how
long one statement could run, so a row lock, a query that would never finish, or a database
that had stopped answering all reached the caller the same way: no reply, for ever. From
outside an instance that is indistinguishable from a deploy, a network fault or your proxy -
and it has been read as exactly that here, by somebody being careful.
It becomes PostgreSQL's statement_timeout on the transaction each request opens, so it is
one statement's allowance rather than the whole request's. SQLite has no equivalent and gets
nothing; what it has is a five-second wait for a contended lock, which is the case that
actually hangs on a laptop and is not configurable.
It does not reach a backup, which is the reason it is worth being precise about where it
is applied. POST /v1/admin/backups legitimately takes minutes on a large database, and it
does not run on the request's own session - pg_dump is a separate process - so no exception
had to be written for it. The same is true of a restore and of subroutine db upgrade.
A request that hits the limit is answered with 503 request_timed_out saying so, and nothing
it was doing was written. Set it to 0 to go back to waiting indefinitely.
There are deliberately no retention settings. The history and the trash are both meant to
have a retention period, and nothing purges anything yet - so events_retention_days and
trash_retention_days were removed rather than documented. A setting that silently does
nothing is worse than an absent one: you can set it, get no error, and believe it. They come
back with what enforces them.
PostgreSQL, and when to switch
SQLite is the default and it is not a toy - it is the right answer for one person, and for a small team that is not writing concurrently. Switch when any of these is true:
- More than a handful of people or agents write at the same time. SQLite serialises writers.
- The database has to live on a different machine from the service. It cannot: a network
filesystem cannot give SQLite the locking it needs, and
serverefuses to start on one rather than corrupting the file quietly. - You want your existing backup, replication and point-in-time recovery to cover it.
The [postgres] extra is not PostgreSQL. It installs psycopg, the client driver, and it
installs perfectly happily with no server anywhere. The server is yours to provide.
On Debian or Ubuntu:
The package creates a cluster, starts it and enables it at boot, so there is no initdb step
by hand. pg_lsclusters should show one cluster, online.
Then a role and a database, and both take the name of the service account:
postgresql+psycopg:///subroutine names no host and no user, so it connects over a Unix socket
as the operating system user - which under the unit is subroutine. The default
pg_hba.conf maps that straight through with peer authentication, so there is no password to
keep anywhere and nothing listening on the network.
--owner is load-bearing, and it looks decorative. Since PostgreSQL 15 the public schema
no longer lets every user create tables in it; the database owner does. Create the database
without it and the first migration stops on permission denied for schema public - a message
about schemas, arriving a long way from the decision that caused it.
Worth confirming before Subroutine touches it at all, because both are much cheaper to fix now than with data in them:
The first should report connecting as subroutine over a socket. In the second, check that the
subroutine row says UTF8: a minimal server image with the locale left at C can give you a
SQL_ASCII cluster.
A database on another machine, or one that wants a password, takes the full URL form
instead - and then After=postgresql.service in the unit is meaningless and can go.
Starting on PostgreSQL
If you are setting a server up now, this is the order - and step 3 is the one that costs an afternoon when it is skipped.
1. Run init with the database named in the environment. It cannot come from
config.toml, because init is the thing that creates that file:
2. Note that it says the value is recorded nowhere, because it is not:
init will not write it for you, and that is deliberate. A PostgreSQL URL routinely
carries a password, and a password belongs with the tokens rather than beside the connection
settings - that is why credentials.toml exists. (config.toml is 0600 and holds
secret_key; what it does not hold is anything that authenticates you to something else.)
3. So write it yourself, into
/var/lib/subroutine/config/subroutine/config.toml:
database_url = "postgresql+psycopg:///subroutine"
Skip this and everything looks fine until the service starts: the environment variable went
with the shell, the unit sets only the XDG paths, and serve looks at the SQLite default,
finds nothing, and restarts every five seconds.
And do not answer that by running init again, which is what the message used to suggest.
Without database_url set it will build a second, empty instance in SQLite, and everything
after that looks healthy: db current reports a schema, list reports an empty backlog, and
the service starts and serves nothing. init warns before doing it - but the remedy is step 3,
not another init.
4. Check before starting anything, as the service account:
A schema revision means the configuration and the data agree. If it names a path ending
.db, step 3 has not taken effect.
All three variables, even though this only reads. Left off, the SQLite default resolves against your data directory rather than the service's - so a check written to catch "it is using the SQLite default" can report a perfectly healthy schema from the wrong file, which is the confusion the paragraph above is warning about.
Switching an instance you already have
Two different situations wear this heading, and the second one is where most people are.
If the instance is already the service account's - you set it up here, on SQLite, and now want PostgreSQL - the copy below is the whole of it. If the instance is yours, in your own home directory under a personal install, and you want it to become a service, read promoting your own instance first: the copy is the same command with two more things to get right, and there are two steps around it that the greenfield path does for you.
If you already have data in SQLite, copy it across first. Do not just change the URL - that gives you an empty database and leaves everything you have in a file nothing is reading. A backup will not do it either: backups are per-engine, so a SQLite one cannot be restored into PostgreSQL.
It is a copy and the original is untouched, so nothing is at risk while you check. The target must be empty; it is migrated to the right schema for you, and every table is read back and counted before the command reports success. Stop the service first, so nothing writes to the old database after the copy is taken.
When the new one looks right, set database_url, restart, and confirm with subroutine db current. Keep the SQLite file until you are sure - deleting it is the only irreversible step
in the whole move, and nothing here does it for you.
It works in the other direction too, which is what you want for a laptop copy of a served instance, or for going back.
The driver is psycopg (version 3), which is what the [postgres] extra installs. Confirm
before starting the service, as the account the service runs as:
A database with no schema in it says so and names which database it looked at, which is the
line to read: run this from your own shell instead and it answers, correctly and uselessly, about
whatever database_url resolves to for you. It is never silently created underneath you
either way.
Promoting your own instance to a service
Everything above starts from init. This starts from an instance you have been using, in your
own home directory, and turns it into the service the rest of this page describes. It was walked
end to end on a live instance with real data before being written down.
Do the account, the virtualenv and the PostgreSQL database from
the sections above first, up to but not including init - the
database already exists and init would make a second one. Then:
1. Find out where your data is. Under a personal install it is wherever your own XDG
directories point, which by default is ~/.local/share/subroutine/subroutine.db. Ask rather
than assume - subroutine doctor prints the three roots in force, and if they are not the ones
you expect then neither is the database.
2. Take a backup, and copy from that rather than from the live database. This is better advice than "stop the service first", and for three reasons: the backup is a single consistent file with the write-ahead log already folded in, nothing has to be stopped, and it cannot change underneath the copy.
subroutine db backup
TAKEN=/home/you/.local/share/subroutine/backups/subroutine-default-20260825T130228Z-9c41d0b7ae52.db
sudo install -o subroutine -g subroutine -m 600 "$TAKEN" /var/lib/subroutine/from-my-account.db
The filename carries the instant and the schema, so put the one it actually printed in TAKEN
rather than reading the path off this page.
A plain cp of subroutine.db is not a copy of your data. The instance runs in WAL mode, so
anything written since the last checkpoint is in the -wal file beside it - and a copy of the
.db alone opens cleanly, counts its rows, and is silently out of date. db backup is the
supported way to get one file.
3. Run the copy as the service account. Not as yourself: db copy writes the tables as
whoever runs it, and with PostgreSQL's peer authentication that makes you their owner - after
which the service cannot write to its own database. Running it as the service account is also
why step 2 put the file somewhere that account can read.
It refuses a source older than this build's schema, so upgrade the copy first if it says so -
db upgrade against the same URL, which is why it is a file the service account owns rather
than the one in your home directory.
4. Write a config.toml, and give it a secret_key. This is the step the greenfield path
hides, because init is the only thing that writes one and this path never runs init. Without
it serve declines to start and says so; before that refusal existed, the instance started,
passed every health check, satisfied doctor, and failed on the first listing longer than a
page.
subroutine config show prints your own instance's settings and where each one came from,
which is the shortest way to see what belongs in the file. The key is not one of them -
generate a fresh one rather than copying yours, since the two instances have no reason to share
it and what it signs is in flight rather than stored:
/opt/subroutine/bin/python -c 'import secrets; print(secrets.token_urlsafe(32))'
Put that in /var/lib/subroutine/config/subroutine/config.toml as secret_key = "…", owned by
the service account and readable only by it, alongside database_url, public_url and whatever
else every setting says you need. Then carry on from
the systemd unit.
5. Decide what happens to the install you started with. Once the service is answering, the old one is a rollback and nothing else, and it is worth being deliberate about it - two copies of your work with no idea which is current is the thing this step exists to prevent.
Reaching the new instance from your own machine is
its own section, and the one thing to know here is that
adding it as a connection while your old local database is still configured is the case
connections add checks for. It will tell you they are the same instance and refuse the second
name. Keep the old database as your rollback by all means - just do not point a second
connection at what is now the same work.
TLS, and why serve refuses without it
Ask for a public bind with nothing in front of it and you get this:
Bare on purpose: this refusal is about the address and public_url, so any installation
prints it and it says nothing about which database you are pointed at.
subroutine serve --host 0.0.0.0
There are three honest ways past it, and one of them is the right one.
Put a proxy in front and keep Subroutine on loopback. This is the recommended arrangement
and the refusal never fires, because the bind is 127.0.0.1 - the default. Set public_url to
the address the proxy serves, which is what clients and agents are told to come back to:
public_url = "https://tasks.example.com"
Bind publicly with public_url set to an https:// address. For the case where TLS is
terminated somewhere the check cannot see - a load balancer, a service mesh. The check is
satisfied by the https:// scheme, so this is you taking responsibility rather than the
program verifying anything. A wrong scheme is caught:
subroutine serve --host 0.0.0.0 # with public_url = "http://tasks.example.com"
--insecure. The honest way to say "this is my home network and I know". It is a flag you
type rather than a setting you forget, which is the point.
A warning was considered and rejected: a warning on a long-running server scrolls out of view in the first minute, and the risk lasts as long as the process does.
The systemd unit
/etc/systemd/system/subroutine.service:
[Unit]
Description=Subroutine
Documentation=https://github.com/simonholliday/subroutine
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=subroutine
Group=subroutine
# systemd creates /var/lib/subroutine, owned by the service account. Subroutine keeps its
# configuration, database and current context under the XDG directories, so point those
# inside it - a service account should not depend on having a home directory.
StateDirectory=subroutine
Environment=XDG_CONFIG_HOME=/var/lib/subroutine/config
Environment=XDG_DATA_HOME=/var/lib/subroutine/data
Environment=XDG_STATE_HOME=/var/lib/subroutine/state
ExecStart=/opt/subroutine/bin/subroutine serve
Restart=on-failure
RestartSec=5s
# Subroutine gives requests already in flight 15 seconds and then exits. This is the outer
# bound on top of that: long enough that a clean stop always wins, short enough that a
# wedged process does not hold a restart up for systemd's 90-second default.
TimeoutStopSec=30s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Only if backup_directory points outside /var/lib/subroutine. Keep the leading '-': it
# means "ignore this path if it is not there", so an unmounted volume costs you a backup
# rather than a service that will not start.
# ReadWritePaths=-/srv/backups/subroutine
[Install]
WantedBy=multi-user.target
ExecStart takes no --host or --port because both are settings; the defaults are
127.0.0.1 and 8471.
TimeoutStopSec is the outer half of a pair, and the order matters. Subroutine stops
accepting, gives requests already in flight 15 seconds to finish, and then exits. systemd's
timeout has to be the longer of the two, or it would kill a shutdown that was about to
complete. Left at the default, stopping a server with a request stuck on something takes 90
seconds - and you find that out during an incident, because that is the only time anything is
stuck for long enough to notice.
When the proxy is on another machine - a router, a NAS, a box running Nginx Proxy Manager
or Caddy - loopback is no longer enough, because the proxy has to reach this host over the
network. Bind wider and say where the proxy serves it, both in config.toml:
host = "0.0.0.0"
public_url = "https://tasks.example.com"
That is the second of the three ways past the TLS refusal above, and it needs no flag: the
https:// scheme in public_url is what satisfies the check. public_url is also published
at GET /v1/meta, so an agent handed a token finds out the address to come back to - which
--insecure would not tell it. Restart, and journalctl -u subroutine should show it
listening on 0.0.0.0.
Naming the machine's own address rather than 0.0.0.0 is better where you can: it will not
follow you onto a café's wifi if this is a laptop, and it makes the intent legible to whoever
reads the file next.
--insecure is for the case where there is no proxy at all and the network is genuinely
trusted - a home LAN with nothing exposed. It goes on ExecStart, because it is a decision
about this invocation rather than a property of the installation:
ExecStart=/opt/subroutine/bin/subroutine serve --insecure
Either way, be clear about what a bind beyond loopback without TLS means: bearer tokens
cross that network in clear, and anything that can see the traffic can replay them. On a home
LAN that is a reasonable trade. It should be one you have made rather than one you have
inherited from a flag you copied. ProtectSystem=strict makes the whole filesystem read-only apart from
what StateDirectory grants, which is why a backup directory elsewhere needs naming - a
ReadWritePaths you forgot shows up as a backup that cannot be written, on the day you need
one. If the directory is on a network mount, add Wants= and After= on that mount's unit
in [Unit] (mnt-backups.mount for /mnt/backups) so it is there before the service starts.
Wants= rather than Requires=, deliberately: a volume that is down should cost you backups,
not the API.
Two endpoints exist for whatever is watching, and neither needs a credential:
curl -s localhost:8471/healthz
curl -s localhost:8471/readyz
/healthz says the process is up. /readyz says it can reach its database, that the database
is at the schema this build expects - which is the one that goes red after an upgrade you have
not finished - and that it is still serving the same instance it started on.
That last one is the answer to am I serving the data I think I am. A process whose database
file is replaced underneath it keeps its handles on the old one, so it goes on reading data
nobody else can see; comparing the instance identity is what turns that from silence into a
503. It is also why a db restore --as-clone makes a running service report not-ready: a clone
is deliberately a new instance, and the process needs restarting.
A reverse proxy
nginx:
server {
listen 443 ssl;
server_name tasks.example.com;
ssl_certificate /etc/letsencrypt/live/tasks.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tasks.example.com/privkey.pem;
# Tell the browser never to come back over plain HTTP. Subroutine issues session
# cookies and refuses to serve publicly without TLS, so the one gap left is a first
# request somebody makes to `http://`. Caddy, below, sets this itself.
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://127.0.0.1:8471;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Caddy, which gets you a certificate on its own:
tasks.example.com {
reverse_proxy 127.0.0.1:8471
}
Whichever you use, set public_url to match. It is published at GET /v1/meta, so an agent
handed a token can find out where it is talking to without being told separately.
Setting it also turns rate limiting on, which matters here more than it looks. The default
is "limit unless nothing outside this machine can reach us", and a proxy in front of an
application listening on 127.0.0.1 is exactly the case where the socket looks private and
the service is not. public_url is how this instance knows the difference.
Telling it which address a request came from
Failed authentications are counted per address, so that guessing a token gets slower. Through
a proxy every request arrives from the proxy, so without help they all share one allowance -
one client hammering with a stale credential makes other people's mistakes answer 429
instead of 401.
Name the proxy and the real caller is counted instead:
trusted_proxies = ["127.0.0.1"]
That is the address this instance sees the proxy connecting from, which is not always the
one you think of as the proxy's. Co-located behind nginx or Caddy it is 127.0.0.1; if the
proxy runs on another machine it is that machine's address on your network.
Name only proxies you control. X-Forwarded-For is written by whoever sends the request,
so this setting is you vouching for a specific peer. Point it at something you do not control
and any caller can choose which bucket it is counted in, which is worse than leaving it empty.
Left empty the header is ignored entirely, which is the right behaviour when nothing is in front.
cors_origins decides more than it used to
Almost everybody should leave this empty, and that includes anybody who has just noticed the web UI. The browser app is served by this instance, from this instance's own address, so it is not a cross-origin caller and needs no entry here. Adding one because a web interface now exists is the one mistake this setting invites.
What an entry does, in full. It lets a page on that origin call this API from a browser and read the replies - which is what CORS has always been - and it lets that page act as somebody who is signed in here. Since browser sessions arrived, a write authenticated by a session cookie is refused unless the page making it is one this instance serves, and this list is how you say another origin counts as one. That is deliberate: naming an origin is already a statement that a browser there may act on your behalf. It is worth knowing you are making it.
* gives that up to every site on the internet. Not in the toothless way a wildcard usually
is - this application echoes the requesting origin back with credentials allowed, so a page
anywhere can read your data and change it, using the session of any of your people who happens
to visit it while signed in. There is no case where a self-hosted instance needs this.
So:
# Only if you run a *separate* front end on another address.
cors_origins = ["https://boards.example.com"]
The instance says what is in force, on every published install. subroutine doctor reports
this setting in its own right - empty, so only this instance's own pages may call it, or
1 named, or, if the list is *, the whole warning above followed by needs attention. It is
stated either way rather than only when something is wrong, so you can tell this is what I
chose from nothing looked. serve prints the same warning at startup for a *.
And a way to check what your instance actually answers, which is the question neither of those asks - they read the setting, this reads the reply:
curl -si https://subroutine.example.com/v1/meta -H 'Origin: https://somewhere-else.example' \
| grep -i access-control-allow-origin
Nothing back is what you want. A line naming somewhere-else.example means this instance is
answering an origin you did not intend to name.
Adding the people
An instance starts with one account - whoever ran init, who is its administrator. Everybody
else is one command. It makes the account, puts them in a workspace with a role, and - if
you say how they will reach this instance - hands you the link or the credential in the same
breath.
The role is member unless you say otherwise, and --workspace can be left out when there
is only one. Both defaults do the same thing: they stop the ordinary case being a decision, and
they still get out of the way - --role viewer narrows, --role admin widens, and once there
is more than one workspace the command asks which rather than choosing.
An account with no workspace can see nothing at all, which reads as a broken credential rather than a missing role. That is why the membership arrives with the account instead of waiting for a second command somebody has to remember.
subroutine user add is still how somebody already here joins a second workspace.
There is no password, so what Keanu needs next depends on what he is going to use - and
--browser and --terminal produce it without a second command at all.
If he is going to open the web interface, hand him a sign-in link. It signs in as whoever it names, once, and stops working after half an hour - so it is handed over the way anything private is, and a second one costs nothing if the first goes stale.
If he is going to use the command line, or point an agent at this instance, issue a token. It is readable exactly once:
Neither is a lesser version of the other and somebody may want both - the link opens a browser session, the token is what a terminal and an agent present. What they must not do is try to use the token to sign in to the browser: a bearer token is not a session, and a narrowed token cannot mint a link for itself either, because a session carries no scopes and issuing one would hand back more authority than the token holds.
Run login link from the server, or from anywhere holding an unrestricted credential. It is
also the way back in for you: if the browser is the only way you administer this instance and
something has gone wrong with it, a link minted at the console is a door that does not depend on
anything else working.
The address in the link comes from public_url, and where that is unset the link falls back
to the address the instance listens on and says that it has. On the arrangement above that is
http://127.0.0.1:8471, which is a working address on the server itself and the wrong one to
hand to anybody else - so if you are proxying this, set public_url and mint a fresh link. An
instance listening beyond this machine with public_url unset refuses to mint one at all,
because 0.0.0.0 names no destination and only you can say which address people use.
--username is for somebody who already has an account; --service-account is for a machine
identity and creates one if there is none. They are separate flags because they are separate
decisions - naming a person under --service-account is refused rather than quietly handing
out their credential. Everything else - scopes, a workspace pin, an expiry - is the same for
either.
A credential is never issued for an account that could not use it: a deactivated account is refused here rather than given a token that fails the first time it is presented.
These commands run from wherever you are. token create, token list and token revoke
go through whichever connection your next write would go to, so subroutine -c work token list
administers the server's credentials from a laptop that holds no database of its own. That
matters because setting an agent up is something you do on the machine the agent runs on, and
until 0.3 these were the three commands that could only be run while sitting on the server.
They still open a database directly when the connection is local - which, on the server, it is. That is not a leftover: the commands that administer credentials have to work when the service is the thing that has gone wrong, and reaching a local database never involved the service. The route follows the connection precisely so that both remain true.
Roles belong to a workspace. member in one is not member in another; each workspace is
seeded with its own, and subroutine user list --workspace <slug> says who holds which.
Deciding membership needs user:admin, which is a different thing from being able to work
there.
Changing what somebody may do is its own command, because adding them and re-grading them are two decisions:
subroutine user role keanu admin
It moves an existing member and turns down somebody who is not one yet, pointing at user add -
the pair each name the other, so whichever you reach for first tells you the other exists.
Somebody added by mistake can be removed with subroutine user remove. That takes away the
membership and not the account: what they wrote stays, and stays attributed to them. Their
membership of any project inside the workspace survives too, so removing and re-adding somebody
does not silently take a private project away from them.
The last account able to administer a workspace cannot be removed from it, or moved out of an administering role - a workspace nobody can administer has thrown away the remedy for every later mistake, including that one, and cannot be repaired from inside. The two commands are refused for the same reason and by the same rule.
On a single-person instance, adding the second account would leave the CLI unable to tell whose
to-do list to show. It does not: user create pins local_user to the account that was already
there and says so. Setting somebody up should not take something away from you.
A screen that only reads
A wall display, a kiosk, a dashboard in a corridor - that is an account with the viewer
role, and it needs nothing built:
The browser genuinely hides the controls rather than refusing them. What a reader may do is resolved per workspace - the role narrowed by whatever the credential allows - and the page is drawn from that answer, so the capture box, Complete, Edit, the status and assignee controls, the comment box and the link controls are absent. A control that appears and then says no is worse than one that is not there, and this is the same mechanism that decides it for everybody else.
Read-only does not mean inert. Controls that change how the page is read stay - revealing the rest of a truncated list, for one - because that is a fact about the reader rather than about the item. A viewer looking at five of twenty-six links with no way to see the rest would be a different complaint with the same shape.
And a screen nobody touches stays signed in. The session's fortnight measures neglect rather than age: every use puts it back, so a display polling all day never lapses, while the same device left in a drawer stops working two weeks later.
Two things to know before you set one up, because neither is obvious and both are the cost of doing it this way:
- Getting it signed in is a person's job, at the machine. A sign-in link works once and lasts half an hour, so the first sign-in - and any after a lapse - means somebody standing at the screen with a fresh link. There is no long-lived read-only credential a browser can hold; a token is for a terminal and will not sign a browser in.
- It is an account, so it is a row in
user list. On an instance where accounts are people that is a small lie, and the remedy is only to name it as what it is -standup,foyer,wallboard- so nobody later wonders who that is.
Give it its own account rather than sharing somebody's. It is revocable on its own, it says who did what if the screen is ever used to write, and turning it off does not lock a person out.
Giving an agent a token
One command does all of it, and it is the one to reach for:
The secret is the one thing masked on this page. Everything else is what the command printed.
Add --store and the second half is done too, which is what you want on the machine the
agent runs on:
Three things it does that doing it by hand does not.
The account, its membership and its credential are one act, in one transaction. An account with no membership authenticates and can do nothing, which reads as a broken token rather than as a missing role - and over a network the alternative is three requests with a half-finished agent if the second fails.
The credential is checked by being presented. What it can do is read back from the instance before the command claims anything, so a scope naming a permission the role does not carry, or a pin on a workspace the account cannot reach, is visible here rather than on the agent's first call.
The last line is not a warning, it is the other half of the job. Until the credential is recorded, the agent's shell resolves whatever the command line resolves - normally your own - so the restriction above bounds the tools and nothing else.
Saying what the credential is for
--profile names a scenario instead of assembling one out of flags. It works on both
agent create and token create, and it expands into exactly the flags below - there is
nothing a profile can express that you could not have typed.
| Profile | Reaches | Writes in | For |
|---|---|---|---|
worker |
one project and everything under it | the same | an agent that owns a project |
collaborator |
the projects named | the ones named with --write |
reads related work for context, writes only its own |
observer |
the projects named, or the whole workspace | nothing | a reporting or reviewing agent |
colleague |
one workspace | the same | a second person, working as they would in their own |
The refusals are the point. A combination that means two things at once is turned down rather than resolved, because a credential that quietly does something other than what you just described is one nobody checks again:
Naming one that does not exist prints all four, because four is short enough to read here rather than go and look up.
The rest of this section is the same work done piece by piece, which is worth reading once because it says what each piece is for.
A token may be narrower than the person who issued it, and this is where that earns its keep. Give an agent a machine identity of its own and only the permissions it needs:
What that buys, checked against a running instance:
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
https://tasks.example.com/v1/tasks
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"nope"}' https://tasks.example.com/v1/tasks
Note what the refusal distinguishes: the role would have allowed it and the token would not. An agent reading that knows it has been deliberately bounded rather than misconfigured.
--workspace pins a token to one workspace. A token can never be wider than the credential
that issued it - token create presented with a narrow token will not mint a broad one, which
is what stops an agent quietly promoting itself.
--project is the other axis, and the one to reach for when an agent works on one thing.
--scope decides which verbs a credential carries; --project decides which items it can
reach at all:
It brings the sub-projects with it, which is why the command says so rather than echoing what you typed: a restriction that stopped at one level would be useless on any tree deeper than one. Everything outside it is not merely absent from a listing - the project does not resolve at all, so the agent is told there is no such project rather than that it may not look.
Name the project by its key. Keys are unique per workspace rather than per instance, so if two
workspaces both hold a web the command refuses and asks which, rather than picking one - an
agent pointed at the wrong tree works perfectly, against the wrong tree.
Give the token to the client as SUBROUTINE_TOKEN. It is never accepted in a query string
and never read from config.toml. --store <connection> writes it to credentials.toml
instead, and is deliberately opt-in: storing a narrow agent token under your own connection
name would quietly narrow your own CLI.
An agent that can also run a shell
This is the part that catches people out, and it is worth understanding before you conclude an agent is bounded.
A credential is resolved per process, not per agent. An AI agent typically reaches an
instance two ways at once: through tools its editor wired up, and by running subroutine in a
shell. Those are separate processes and they resolve credentials separately - so configuring
the agent's tools with its own token does nothing about the shell, which finds whatever the
command line finds, normally yours.
The result is an agent that is itself half the time and you the other half. That is worse than plainly acting as you, because it is partial: check the event log and the agent's own name is there, on the half that went through its tools.
--store settles it for every agent on this machine, and a project's own settings can
settle it for one project - A different agent in each
project. Credentials are looked for in this
order:
SUBROUTINE_TOKEN_<CONNECTION>in the environment - the connection name upper-cased, with anything that is not a letter or a digit as an underscoreSUBROUTINE_TOKEN, for the default connection only- whatever the connection's own
token_envortoken_commandnames credentials.toml- the agent's token where one is stored and this is an agent's process, and yours otherwise
The first wins. Step 4 is where the two of you stop sharing a name, because it is the only step that can tell you apart: you are the same account, in the same directory, reading the same files, and the one thing that differs is the environment each process was started in.
How it knows. An editor sets a variable on every process it starts and on nothing above
itself - CLAUDECODE for Claude Code, which is the shipped default. A connection can name a
different one:
[connections.work]
url = "https://tasks.example.com"
agent_when = "SOME_OTHER_EDITOR"
Only its presence is read, never its value. It is not a place a token can live, and it decides attribution rather than authority - claiming to be the agent selects the narrower credential, so there is nothing to be gained by it.
It costs nothing until you use it. With one token stored, every step behaves exactly as it did. Remove the agent's token and the mechanism is gone, completely.
SUBROUTINE_TOKEN_<CONNECTION> set where the agent runs is the other way, and it is per
project. An editor extension has no command to prefix, and a shell profile reaches your
terminal rather than the agent's - but Claude Code reads an env block from a project's own
settings and gives it to everything it starts there, which is how one checkout gets an agent of
its own while the rest of the machine keeps this one. A different agent in each
project is the whole of it.
Check it rather than assuming it, from inside the agent's own shell:
subroutine whoami
A person's name on that line is the split, and it is the only place it is visible. Run the same command in your own terminal: it should name you, and the pair is the check rather than either answer alone.
The cheaper answer, where it fits: your own token does not have to be on a machine an agent
uses. If you work from your laptop and only agents work on the build box, then
credentials.toml there should hold the agent's credential and nothing else - and the split
stops mattering, because both halves are the agent.
Reaching it from your own machine
Everything above set up a server. This is the other end: your own account, on your own laptop or on the same machine, listing the server's work beside your own.
It is a connection, and your own database is one too. That is the whole design:
subroutine agenda asks every connection and merges the answers, so the dentist and the
stand-up appear in one list rather than in two tools. Your own database is called local and
exists whether or not you declare it.
A server account and your own account are separate installations on one machine, and that is
correct rather than a mistake. Subroutine keeps its files under the XDG directories, so the
service account's instance lives under /var/lib/subroutine and yours under ~/.config and
~/.local/share. Running subroutine list as yourself shows your items and always will;
subroutine config show will say database_url … [default] pointing at your own SQLite file.
Reaching the server is not a matter of changing that - it is a matter of adding a connection
beside it.
One command, and it asks for the token - which you issue on the server first, as the service account. That is further down this section, and it is worth reading before you start: a token is shown once and stored nowhere, so there is nothing to go back and look up.
subroutine connections add work --url http://127.0.0.1:8471
The name - work here - is yours. It is the first segment of every address the server's
items print as, so work/acme/#42, and two people connected to the same server may call it
different things. A name must start with a letter, because one made only of digits would read
as a ref.
It reaches the instance before it writes anything, with the credential you just gave it - the same call every listing begins with. A mistyped address, a revoked token, a proxy answering instead of the server: each is refused there and then, with nothing recorded, rather than becoming one line of failure among tomorrow's results. That is also why it can tell you the name the server knows you by, which is the only thing that confirms you pasted the token you meant to.
If the machine has no instance of its own - a second laptop, a workstation whose work all
lives on the server - it also makes that connection where new work goes, and says so. On a
machine that already has its own list it leaves that alone, because moving somebody's writes
off their own to-do list is their decision. --default asks for it either way.
Other things it takes: --read-only to reach an instance and refuse to write to it, and
--token-env or --token-command to fetch the credential from the environment or from
pass, gpg, secret-tool or a password manager instead of storing one.
There is no --token, deliberately. A credential passed as an argument lands in shell
history and in the process list. Piping one in works, for a script or an agent:
pass show work/subroutine | subroutine connections add work --url http://127.0.0.1:8471
Tokens live in their own file and never in config.toml, so that a configuration
file can be copied, committed or pasted into a bug report without taking a credential with it.
credentials.toml is written chmod 600. If your shell already has SUBROUTINE_TOKEN set,
that is another way in and needs no file.
It is not secret_key, which is the only thing in config.toml that looks like a
credential and is the wrong one. Every instance writes its own at init - the server has one
already - and it signs pagination cursors and nothing else. Copying it across achieves nothing.
And there is nothing to look up. Only a hash of a token is stored, so no command can show
you one that was issued earlier; token list prints prefixes, which is what revoke takes. If
you have lost a token, issue another and revoke the old one. Issue it on the server, as the
service account:
That is the only time the secret is shown. See Giving an agent a token for narrowing one.
Then it just appears, with each row saying where it lives:
subroutine list
subroutine connections is how you check it, and it is worth knowing about because it
stays out of subroutine --help until a second connection exists - which is to say, until the
thing you are checking has already worked. connections add is hidden with it, for the same
reason and with the opposite effect, which is why this page names it: nothing on a machine can
tell "not set up yet" from "never will be", so the command that fixes the second cannot
announce itself to the first. It lists what this machine reaches and, for each, which of the
four places its token came from, which is the question that actually bites:
subroutine connections
No token is printed and none can be recovered from what is. If your new connection is missing
from that list, config.toml is not being read the way you think - check the table name and
the spelling of [connections.<name>].
Each row prints the shortest address that resolves - a bare number for your own, and the connection and workspace for anything that needs them. Whatever it prints is what you can type back, which is the point: a bare number beside an item on somebody else's server would be an invitation to act on the wrong one.
subroutine use work changes which connection a write goes to - subroutine add and the
rest. It never changes what you can see: reads always span everything reachable, which is what
makes switching safe.
If a connection cannot be reached, the rest of the list still prints and one line says which one failed. That is deliberate: being told nothing about your own to-do list because a work server is down is the outcome this design exists to avoid.
connections add writes a [connections.<name>] table, and the file is still yours to edit
for anything it does not ask about. The other keys that table takes: display_name for what
it is called in output, read_only = true to refuse writes to it from this machine,
token_env or token_command to fetch the credential from the environment or from pass,
gpg, secret-tool or a password manager rather than from a file, timeout_seconds, and
enabled = false to keep a connection configured and switched off. Anything else in that table
is refused by name rather than ignored.
Two connections may not name one instance, and connections add refuses a second name for
a server this machine already reaches. A merged listing would count everything on it twice, so
the refusal is at the moment you can pick a different word rather than on the first listing -
where it withholds every result and can only tell you to go and edit a file.
Reaching it from an agent, with nothing installed
Everything above assumes the person has Subroutine on their machine. An agent does not need
it. The server speaks MCP itself, at POST /mcp, so a coding agent reaches this instance with
a URL and a token and nothing else - no Python, no package, no config.toml.
This is the case worth designing for: somebody works with you for a month, you send them a URL and a token, and their agent files work against your instance the same afternoon.
Issue them a credential exactly as above - subroutine agent create, or token create --service-account - and give them two things:
URL: https://subroutine.example.com/mcp
Token: sr_…
The easiest thing to tell them is to install the plugin, which asks for exactly those two things and carries the working practice as well:
claude plugin marketplace add simonholliday/subroutine
claude plugin install subroutine-remote@subroutine
It has no fields for a command or a connection, because it needs neither. Until they paste an address in, it sits there configured-but-idle rather than reporting a fault.
Tell them they will need Git, though. The marketplace is a repository and the first command clones it, so on a machine with no development tools that step refuses before anything of ours is reached. It is the only prerequisite on their side, it is Claude Code's rather than ours, and it is invisible to everybody whose machine already has it.
Or, without the plugin, one command:
claude mcp add --transport http subroutine https://subroutine.example.com/mcp \
--header "Authorization: Bearer sr_…"
The credential is the boundary, and it is the same one everywhere. The scopes, the project
scope and the workspace pin apply here exactly as they do to /v1 and to the command line, so
an agent given a read-only token over MCP is read-only over MCP. Rate limiting applies too, per
token.
Put ?workspace= in the address you hand over if the token reaches more than one
workspace:
https://subroutine.example.com/mcp?workspace=projects
This is yours to get right rather than theirs. Without it, an agent whose token reaches
several workspaces has every read refused as ambiguous - the refusal names the workspaces it
could have meant, but the person receiving it has no way to know which one you intended, and on
the plugin path the remedy is a settings field they would have to be told about. You know the
answer; put it in the address - or pin the token to one workspace when you issue it, with
subroutine token create --workspace, and the address needs nothing however many workspaces
the instance holds.
It is a default rather than a limit - a call may still name another workspace, and a token pinned to one is what actually narrows access.
The endpoint needs the instance to be reachable from wherever the agent runs. Claude Code connects from the user's own machine, so a LAN address or a VPN-only host is fine. The Claude desktop and web clients connect from Anthropic's servers instead, which means a publicly reachable address - see A reverse proxy.
GET on the endpoint answers 405, which is correct rather than a fault: this server has
nothing to send that a client did not ask for, so there is no event stream to hold open. A
client that tries carries on without one.
Calendar feeds
A person can point Google Calendar, Apple Calendar, Outlook or Thunderbird at their work here, and see anything with a date beside the rest of their week. There is nothing to set up: the feature is on unless you turn it off, and each person makes their own subscription with
subroutine calendar create "My work"
which prints one address, once. That address is the credential - there is no header to send and no account to sign in to, because a calendar application has nowhere to put either. This is the whole of what you need to know about it, and it has three consequences worth reading before you decide whether to leave it on.
The address is built from public_url, so an instance that has not been told its own
address mints the feed and says it cannot give you one. Set public_url and run
subroutine calendar reset <reference>; the feed itself was never broken. Nothing guesses from
the Host header, deliberately - a proxy rewrites that, and a guessed host is a secret sent
somewhere nobody chose, every fifteen minutes, for as long as the subscription lives.
A leak is not detectable from here. The address ends up in a phone's account settings and
in whatever synced them, and a fetch from somewhere unexpected looks exactly like a fetch from
somewhere expected. What there is instead is the last polled column
subroutine calendar list prints - a subscription nobody has fetched for months is one to
revoke, and revoking costs nothing.
Revoking is immediate and resetting keeps the feed. subroutine calendar reset gives a
subscription a new address and stops the old one working that instant, keeping its name and its
scope; subroutine calendar revoke stops it for good. Whoever held the old address is not told -
there is nobody to tell - so their calendar quietly stops updating and you send them the new
one the same way you sent the first.
Two settings decide the shape of this, and both are in the settings table:
calendars_enabled |
Turn the whole feature off. Every feed address then answers 404, exactly as an address naming nothing does - so an instance with it off is indistinguishable from one that never had a feed - and creating or resetting one is refused by name. Listing and revoking still work, because turning a feature off must not be a way to trap a credential somebody cannot end |
rate_limit_polls_per_minute |
How often one feed may be fetched. Its own bucket rather than a share of the ordinary allowance, because a misconfigured calendar client should not empty the one its owner's terminal draws from |
Turn it off if you would rather not have bearer credentials in URLs at all. That is a defensible position and it is why the setting exists: nothing else here puts a secret in a path, and the reason this does is that no calendar application will send anything else.
A feed shows what its owner may see, asked afresh on every fetch. Somebody who loses sight of a project stops seeing it in their calendar the same day, and there is no way to make a feed of somebody else's work - the owner is whoever ran the command. Marking an account as having left stops its feeds too, which is why offboarding needs nothing extra here.
Backups
Unset, backups go into the instance's own data directory, and for a single machine that is a real backup: it protects you from a bad migration, a restore you did not mean, a delete nobody meant, and a database that corrupts itself. It fails for exactly one thing, which is losing the device.
So if the device matters, set a directory on another volume:
backup_directory = "/srv/backups/subroutine"
That is a judgement about what you are protecting against, not a requirement. Nothing here refuses a path, warns about one, or nags about how old the newest copy is - how old is too old depends on whether this is a laptop or a server, and only you know which.
A network mount is the intended destination and works. The file is built locally and then
copied, because SQLite's VACUUM INTO cannot write to an SMB share any more than the live
database can live on one; delivery is then verified where the file landed, by size and by
reading the schema version back out of it. A copy that fails verification is deleted rather
than left looking like a backup.
Permissions may not survive the trip, and that is worth knowing rather than working around.
A backup is written 0600, because it holds every task, comment and token hash. Many network
mounts fix their permissions at mount time - CIFS with file_mode=, for instance - so the
chmod succeeds and changes nothing, and the file is as readable as everything else on the
share. If that matters, the answer is on the share rather than here.
The name is subroutine-<instance>-<when>-<schema><suffix>, and the suffix says how to
read it back - .dump for a PostgreSQL archive, which pg_restore loads, and .db for a
SQLite copy, which is a database. They are not interchangeable in either direction, and a
restore refuses the wrong one rather than discovering it partway through.
Match on subroutine-* in a retention script, never on one suffix. A glob written against
*.sql matches nothing on SQLite, nothing on a current PostgreSQL instance, and only the
backups taken before 0.8.8 - which is the worst of the three, because it looks like it works.
.sql files taken by an earlier version still restore. PostgreSQL backups used to be
plain-format scripts, and they are still read; nothing writes one any more. The change is that
a script is executed by psql, which runs backslash commands embedded in it, and an archive is
loaded by pg_restore, which has no such notion - so a tampered backup has nowhere to put an
instruction. If you keep old .sql backups where anybody else can write, take a fresh one and
treat the old files as you would any other file you did not write.
--keep N prunes to the newest N routine backups afterwards. Run it from a timer - it names
every file it deletes, so the timer's log is the record of what went.
Three kinds of copy share this directory and each has its own lifetime. A routine backup is
one you asked for, and nothing removes it unless --keep says so. The other two are copies the
program takes on its own initiative at a moment it knows is risky, and they bound themselves:
db upgrade keeps the newest backup_keep_upgrades rollback points, three unless you say
otherwise, and db restore keeps its safety copy for a week. Neither ever counts or deletes a
routine backup, and --keep never counts or deletes either of them.
That separation is the point rather than a detail. One shared counter meant an hourly
--keep 24 reached back a day and deleted the rollback point for the upgrade that had gone
wrong the day before - the copy you want precisely then. It also meant nothing ever removed a
rollback point at all, so one accumulated per upgrade for ever.
A copy taken before this rule existed says purpose not recorded, and counts as routine.
Nothing recorded what it was for, and keeping it is the safer of the two readings. If a pile of
those has built up from past upgrades, one deliberate db backup --keep N clears it - until
you run that, they stay.
This governs the copies on this machine. Anything shipping backups off the node keeps its own retention, and the two are separate mechanisms with separate lifetimes.
Backups are written owner-only, like the database and config.toml. A backup is the whole
database, so it is exactly as sensitive as the thing it copies.
Every backup carries the schema version it was taken on, inside the file - the filename echoes it, but the value inside is the authority, because anybody can rename a file. Restoring an older one works and offers you the upgrade; restoring a newer one is refused outright, because there is no downgrade and a partial read is worse than a clear failure.
An agent can take one before attempting something bulk:
curl -s -X POST -H "Authorization: Bearer $TOKEN" https://tasks.example.com/v1/admin/backups
That endpoint needs instance:admin, which no role carries - only an administrator of the
instance holds it, so an ordinary agent token gets a 403 naming the permission.
There is deliberately no restore endpoint. Putting a backup back replaces the database the
serving process has open, and recovery has to work when the service will not start - which is
exactly when you need it. subroutine db restore is the only way, and it will not run without
you saying which kind of restore this is:
subroutine db restore <file> --recover # this instance, lost data, same identity
subroutine db restore <file> --as-clone # a copy to poke at, new identity
Bare on purpose: <file> is a placeholder and this is the two flags side by side rather than
a command to run. The one you actually type is below, in full.
Neither is a safe default. A recovery keeps the instance's identity, which is what agents key their caches on; a clone mints a new one, so that two live instances never claim to be the same. Getting it wrong is invisible in both directions, so you are asked.
Stop the service before you restore. A running one keeps its file handles on the database
that was just replaced: it goes on writing to something with no name any more, its reads are
stale, and its next checkpoint can land on top of the restored file and corrupt it - while the
API answers normally throughout. Subroutine refuses when it can see another connection, and
--force overrides that for the case where it cannot:
/readyz is the exception, and only since it began comparing the instance identity: it used to
answer ready throughout this, which is how the problem was found in the first place. Do not
rely on it to notice - it can only see a replacement that changed the identity, so a --recover
restore of the same instance underneath a running process is still silent, and stopping the
service first is still the answer.
Two more things this will not do to you. A backup from the other engine is refused before
anything is dropped - a .db is a SQLite database and a .dump is a PostgreSQL archive,
they cannot be read by each other's tools, and subroutine db backups names the engine when a
directory holds both. To move an instance between engines, use subroutine db copy, not a
backup. And the safety copy taken before a restore is never allowed to block the restore:
if the database being replaced is too damaged to copy - which is the usual reason to be
restoring at all - you are told so plainly and asked whether to go on, rather than refused.
Mark a production instance as one worth protecting, and destructive commands will require agreement before touching it:
protected = true
A setting Subroutine does not recognise is named on every command rather than ignored, with
the nearest real one suggested. protectd = true is not a protected instance and never was;
before, nothing said so.
Credentials
subroutine token list shows every credential this instance has issued - its prefix, who owns
it, what it can reach, when it expires and when it was last used. No secret is stored, so
there is nothing in that listing to leak, and the prefix is what revoking takes:
Revoking is immediate: a revoked credential is checked on every request rather than cached, so there is no session to wait out. That is the answer to "a key leaked", and it is why the listing shows what each one can reach - the question at that moment is which of them could write.
Keeping credentials out of your logs
A sign-in link travels in a URL, so it reaches every access log that sees the request. It
has to: a link is opened by clicking one, and a click is a GET. subroutine serve redacts it
from its own access log - you will see GET /signin?link=REDACTED rather than the secret - and
it does the same for an API token somebody has wrongly put in ?token=, ?api_key= or
?access_token=, which is refused but is a real credential by the time it is refused.
A calendar feed's address carries its credential in the path, not in the query, and that is
the one you should care most about: a subscription polls roughly every fifteen minutes for as
long as somebody keeps it, and a feed secret does not expire. subroutine serve redacts it too -
GET /v1/calendars/145ed614/REDACTED.ics - keeping the short prefix, which identifies the feed
so that you can still tell which subscription is polling. subroutine calendar revoke <ref>
ends one, and reset gives it a new address without disturbing anything else.
Your proxy logs the same request line, and we cannot reach that. If you run one, tell it to
drop the query string from the paths it records. In Nginx that is a log_format using $uri
rather than $request:
log_format subroutine '$remote_addr - "$request_method $uri" $status';
access_log /var/log/nginx/subroutine.log subroutine;
That handles the query and not the path, so it is not enough on its own for calendar feeds -
$uri is exactly the part a feed's secret sits in. If you serve feeds through a proxy and keep
its access log, either turn logging off for /v1/calendars/ or rewrite the path before it is
recorded:
location /v1/calendars/ {
access_log off;
proxy_pass http://127.0.0.1:8471;
}
Two things worth knowing rather than guessing:
- A logged link is usually already spent, because the log line is written when the response goes out and the link is consumed before that. The exception is the confirmation page - if the browser was already signed in as somebody else, the link is deliberately left usable so that saying no costs nothing, and it stays usable for the rest of its half hour.
- A link is good for thirty minutes and works once. That is the reason a lapse here is
worth fixing rather than panicking about, and
subroutine login revoke <username>cancels every unspent link and live session that person has.
If you run the app under your own uvicorn or gunicorn rather than subroutine serve, call
subroutine.api.logs.redact_access_logs() before starting it; the filter belongs to a logger in
your process, and nothing else installs it for you.
Upgrading
Before and after, subroutine doctor says whether this machine is coherent. One command:
what is running and where it came from, which configuration it is reading, what each connection
answers, and when a backup was last taken.
That is a real run on a published instance, pasted whole. Two tokens in it are not what the
command printed: the backup directory, which on the machine this came from names a host, and
the account name, which was a real login - a public page carries neither, and laurence is the
operator throughout these pages. Everything else - the versions, the schema revision, the
counts, the alignment - is as it came out.
A published instance prints these nine lines. An unpublished one prints eight, and the
difference is not the one you would guess: it shows a single exposure line saying nothing is
reachable from outside, instead of cors_origins and rate_limit, because neither setting is
in force when nothing can reach it. So if you are comparing this against a machine you have not
published yet, expect that substitution rather than a missing line.
Run it as the service account, with the same three variables as everything else in this
section - that is the whole point of the config, data and state lines. If they are not
the ones the unit sets, you are looking at a different installation from the one that serves
requests, and everything below them is true about the wrong machine.
The line that should move is program, and only in that run. That is the whole
before-and-after: it names the version and the path of the copy this procedure upgrades, so a
number that has not changed means step one did not take. local moves as well when the release
carried a migration, and stays put when it did not - the CHANGELOG says which before you start.
Run doctor from your own shell and program will not move, correctly. It is shorter and
it works, so it is what an operator reaches for - and it reports your install,
~/.local/bin/subroutine, which this procedure never touches. The server then appears as a
connection line rather than as program, and that connection is what moves. An unchanged
program there is not a failed upgrade; it is a different question being answered.
And your own client is now behind the server, which is expected and says so. Nothing
upgraded it, so it is an older program talking to a newer instance - subroutine whoami prints
both versions and ends with the program and the instance disagree, so a call may be refused for
a field one of them does not have. Meeting that for the first time immediately after an upgrade
reads like damage and is the check working. Upgrade your own copy the way you installed it,
whenever suits; nothing on the server is waiting for it.
It exits non-zero when something needs attention, so it can be the last line of an update script. It talks only to the instances you have configured.
Subroutine does not check for updates unless you tell it to. With nothing set, an instance can run for years without making an outbound request. Asking is something you do:
It answers in two or three lines - what is running, what has been released, and whether taking it changes the database schema. That last line is the reason the command exists: it is the difference between planning a short outage and meeting one halfway through an install.
It reports what is running, which is not always what a package manager thinks is installed - an editable install carries the version it was made at. And it changes nothing at all, so it is safe on a machine you have not decided about yet.
Or tell the instance to ask for you. With check = true under [releases] it asks once a
day, only while somebody signed in is using it, and says what it found where each person can
act on it:
- This instance's log gets a warning while it is behind, naming how many releases behind it is and whether upgrading changes the database, and a note when a check fails. Each is written when the answer changes rather than every day, and once more after a restart.
- The browser shows the same news to whoever may administer the instance, and to nobody else.
subroutine whoamisays which of the program, the plugin and the instance is behind and what to type, names the newest release when none of them is, or says that the last check failed. The baresubroutineadds one line under the agenda while the program itself is behind.
A development build says nothing, because its version cannot say whether it already contains a release.
The package manager moves the code. Subroutine moves the database. In that order, and it will not try to do the first for you - a tool that installs software over itself fights whatever installed it, cannot do it safely while running, and is worse at it than your package manager.
Those three variables are not decoration, and this step is the one place leaving them off
fails quietly. upgrade acts on a database, and it finds that database through
configuration - so without them it reads your config.toml rather than the service's, finds
whatever database that names, and reports on the wrong one. It will look like it worked. They
are the same three the unit sets and the same three init was
run with; a test fails the build if the two lists stop matching.
Stop the service before upgrading, not after. The order above is deliberate: install first and start last, so there is never a moment where new code is serving an old database.
If there is one anyway - and a mistimed deploy is the ordinary way to get there - the instance
does not pretend everything is fine. /readyz returns 503 naming both revisions, and every
write is refused with a 409 saying the same thing while reads go on being served. That is a
deliberate choice rather than a half-measure: refusing to start would take the /readyz sentence
away, so you would get a connection refused and have to go to the journal, and it would stop
somebody looking something up over an upgrade they are not involved in.
What it does not protect you from, said plainly: a migration that backfills an existing
column leaves that column present and empty until it runs, so a read can be complete, plausible
and wrong. Refusing writes stops you building on top of that; it cannot stop you being shown it.
Six of the migrations shipped so far backfill. subroutine db upgrade is the supported path,
and /readyz is how you find out you are not on it.
subroutine db upgrade is the whole of the second step, and its value is the ordering rather than
any one part of it: report what is installed and what the database is at, back up and verify the
copy where it landed, migrate, then read the schema back rather than assuming.
It is safe to run when there is nothing to do - it prints the three numbers and stops, which is also the cheapest way to ask the question:
Read the version on that first line, because it is the only part of this that can tell you
step one worked. A release that carries no migration and an upgrade that never happened print
the same two schema numbers and the same Nothing to do. - so if the version is not the one you
just installed, the database is fine and the software did not move. That happens more easily
than it sounds: a copy installed from a checkout carries a development version, which compares as
newer than anything published, so pip install --upgrade declines it without failing. The
command says so when it sees one.
Add --yes when the instance is marked protected and there is no terminal to answer the
prompt - a timer or a deploy script. On a protected instance without it, the command says
what it was about to touch and stops.
If the migration fails, the message says where it stopped and where the backup is, with the
db restore … --recover command spelled out. It does not claim the database is unchanged:
Alembic runs each migration in its own transaction, so an upgrade spanning three releases can
leave the first two applied, and that is exactly the case where somebody needs the truth.
You should not usually meet the check, but here is what it looks like. Run anything against a database this build does not match and it is refused, with the direction of the mismatch deciding the remedy:
A database newer than the software is refused the other way - update the software, because
there is no downgrade. The administrative commands are deliberately outside the check:
db current, db backup, db backups, db restore and upgrade itself all keep working
while it is firing, because they are what you reach for once it does.
curl localhost:8471/readyz makes the same comparison for the served path and has always done
so. subroutine --version prints the release and the schema this build wants.
Whether a release needs a migration at all is on the release itself. Each entry in CHANGELOG.md that moves the schema carries a notice saying so, with the revisions it moves between - and CI refuses a release that moves the schema without one, by comparing the migration history against the previous tag rather than by trusting anybody to remember. So the question "will this upgrade need downtime?" is answered before you download anything, which is the whole point.
Tracking a git ref rather than a release
Everything above assumes a release from PyPI, which is the ordinary case. Tracking the repository is a legitimate choice for a self-hosted tool, and four things on this page work differently for it.
Upgrading is the same command with the URL in it, because pip will not upgrade a direct reference by name:
Check that it took, because pip may say nothing either way. On a direct URL it clones, resolves the commit, builds the metadata and prints neither Successfully installed nor already up to date - so its output cannot tell already at HEAD from declined to replace. The version is what answers:
/opt/subroutine/bin/subroutine --version
The part after +g is the commit. If it has not moved, neither has the software, whatever the
install printed.
subroutine db upgrade --check asks about releases, so it cannot answer this question. It
compares what is running against what has been published, which for a build from a branch is a
comparison between two different things - and it says so rather than guessing:
subroutine db current is what replaces it, and it is the one to run between installing and
upgrading. It compares the database in front of it against the build that is now installed,
which is exactly the question --check was being asked - and it needs the same three
variables as everything else here, for the same reason:
When the two match it says so in one line - Schema is at cfeea5d4dd35. - and there is nothing
to do.
Run bare, it answers about your own account's database and the answer looks just like the one you asked for. That is the paragraph above this section arriving in the one place it is easiest to skip: this command reads a database, it finds it through configuration, and without the variables the configuration it reads is yours. It says which database it looked at, so the sentence to check is the path - not the schema.
And a schema change arrives with no notice. The migration notice at the top of a changelog
entry belongs to a release, and CI refuses a release that moves the schema without one. From a
branch there is no release to carry it, so the mechanism that exists to stop somebody meeting a
migration halfway through an install never fires. db current between the two steps is the
substitute.
subroutine db upgrade itself needs no different handling - the same invocation as
above, variables and all - and says the useful half unprompted:
What the licence asks of you, which is almost nothing
Subroutine is FSL-1.1-ALv2. Running it, modifying it and serving it to your own people are all free and unconditional - internally, commercially, at any size, for ever. There is no obligation to publish anything, and nothing here is triggered by having users.
What the licence withholds is any use that competes with Subroutine: making it, or anything built from it, available to others in a commercial product or service that substitutes for it, or for a product or service offered with it, or that offers the same or substantially similar functionality. Hosting it for your own organisation is free; selling other people access to it, or to something built from it, is not, and neither is selling a product built from it that does what it does. If that is what you are setting up, write to simon.holliday@protonmail.com first - a commercial licence is available by agreement, and it is cheaper than finding out afterwards.
Each release becomes Apache-2.0 two years after it ships, automatically.
source_url is a promise rather than an obligation, and that is worth knowing before you
change it. Under the AGPL this page carried a legal requirement; under the FSL it does not,
and the field stays anyway because somebody using an instance ought to be able to find the
source of the thing they are using. It is a setting so that a fork can point at its source
rather than at this repository, which it would otherwise do while being wrong:
source_url = "https://git.example.com/us/subroutine"
Leave it at the default if you have not modified anything, and change it if you have.