Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome to Docwright

Tools of power and productivity for students, writers, and other knowledge workers

If your work is words and files, this site is for you: students, writers, researchers, and other knowledge workers. Programmers have had the tools to protect and publish their work for years, but their documentation is often written by developers for developers. Docwright explains those tools for the rest of us. Everything here runs on free software, and none of it requires you to know how to code.

Start here

If all of this is new to you, read these six in order, about half an hour all told. The times are for reading; the doing takes longer.

  1. Terminal basics, 3 minutes. The one skill every other guide leans on.
  2. Whys of backup, 8 minutes. Why a backup comes first, and why it stays offline.
  3. Getting started with Borg, 6 minutes. Your first encrypted backup on a USB stick, and your first restore.
  4. Whys of version control, 4 minutes. Keeping every draft without keeping every file.
  5. Getting started with Git, 6 minutes. Your first commit, and your first old version brought back.
  6. The Choosing-the-OS TL;DR, 5 minutes. The case for leaving Windows and macOS, and which Linux to pick.

If you already know you’re going to be leaving Windows or macOS, then start the move instead: read Choosing the OS and come back for the rest once you’ve landed. Just never wipe a machine before you have copies of your files; on Windows, where Borg doesn’t run, plain copies of your folder on two USB sticks will hold you until the move is done and the real backups begin.

What the site covers

Backup protects your labour when a laptop dies, a file goes missing or a machine is stolen; knowing everything can be restored also clears your head for the work itself. Version control sounds technical but mostly means keeping a history of your drafts, so you can retrieve the version you wrote last Thursday; it turns out to be much more than that. System security is the move to Linux and the hardening after it: a big move, but worth it for productivity in the long run, not just security. A local AI model runs on your own machine, so what you write doesn’t get sent off to someone else’s servers. Publishing puts your writing online as a blog, a book site like this one, or a documentation site.

Finding your way

The sidebar lists every chapter; b opens and closes it, s searches the whole site, and the left and right arrow keys move between chapters. The printer icon turns the site into a single page for printing or saving, and the pencil opens the page’s source on GitHub, so you can send a fix if you spot a mistake. Using the site has the rest of the keys and icons.

Using the site

The keys, icons, and shortcuts for getting around

The sidebar lists every chapter of the site. The menu button (three horizontal bars) at the top left opens and closes it, and so does the b key. Click any chapter to open it. On a narrow window, a phone especially, the sidebar starts closed; the menu button brings it up.

The menu bar along the top holds the icons described below. Clicking the site’s name in the middle takes you back to the top of the page. As you scroll down, the menu bar slides out of the way; scroll up a little and it comes back.

The ← and → keys move you to the previous and next chapter; the large arrow buttons do the same, at the sides of the page on a wide screen and at the bottom of the page on a narrow window or a phone. ↑ and ↓ scroll the page, and PgUp, PgDn, Space and Shift+Space scroll a whole screen at a time. Press ? any time for the full list of keys.

This is not unique to Docwright, but in case you didn’t know: Alt+← and Alt+→ move you back and forward through your browser history. If a link took you somewhere, Alt+← brings you back.

On a phone or tablet

There’s no keyboard, so here’s what stands in for each key.

To do thisOn a computerOn a phone or tablet
Open or close the sidebarbTap the three bars, or swipe right from the left edge to open it and swipe left to close it
Previous or next chapter← →Tap the arrow buttons at the bottom of the page
Searchs or /Tap the magnifying glass
Back to the topClick the site’s nameTap the site’s name
Back to where you wereAlt+←Your phone’s back button or back gesture
Read offlineClick the cloud, then Save for offlineTap the cloud, then Save for offline; on an iPhone or iPad, open the site from your Home Screen first (in Safari: Share, then Add to Home Screen)

Swipes need to be quick: a slow drag scrolls the page instead.

IconWhat it does
Three barsOpens and closes the sidebar. Same as b.
PaintbrushSwitches between the site’s colour themes. Your pick is remembered for your next visit.
Magnifying glassOpens the search bar. Same as s or /.
PencilOpens the source of this page on GitHub, so you can suggest a fix if you spot a mistake.
PrinterLays the whole site out as one long page, for printing or saving as a PDF. To print only the page you’re reading, press Ctrl+p instead.
CloudSaves the site in this browser, to read without a connection, or gives you a copy to keep on any computer. Once the site is saved, the cloud becomes a tick. See Reading offline.

One icon lives outside the menu bar: hover over any code block and a copy button appears at its top right. It copies the code exactly as written, ready to paste into the terminal.

Open the search bar (the magnifying glass, s or /) and start typing; matching chapters and headings appear as you type. ↑ and ↓ move through the results, Enter opens the one you’re on, and Esc closes the search bar.

Reading offline

The cloud at the right end of the menu bar gives you two ways to read Docwright without a connection.

Save for offline keeps the whole site in the browser you’re using, search included. You need to be online once, to save it; after that the site opens from your saved copy, online or not. Once it’s saved, the cloud turns into a tick, or a plane while you’re offline. When a new version is out, a dot appears on the tick and the top of each page says so: tap the tick, then Update to v…, which downloads only what changed. Remove saved copy takes you back to the live site. The copy lives in that one browser. On an iPhone or iPad, add the site to your Home Screen first (in Safari: Share, then Add to Home Screen) and save it from there: Safari deletes a site saved in the browser after seven days of browsing without opening it.

Download a copy gives you the whole site to keep on any computer, including one that’s never online. Unpack it and open index.html in the folder it makes; it needs no connection. Under the button are the download’s SHA-256 checksum and a checksum file, and the README.txt inside shows how to use them to check the download on Linux, macOS or Windows. A downloaded copy doesn’t update itself: its cloud shows which version you have and the site’s address, where you can get a newer one.

If the cloud offers only Download a copy, this browser can’t keep the site offline: use another browser, or the download.

The version line

At the very bottom of the sidebar, under the last chapter, you’ll find a small grey line like v1.2 · updated 2026-03-14 · a3f9c1e. It tells you which build of the site you’re reading: the version, the day it was published, and the exact commit it was built from. The commit is a link, so you can see on GitHub exactly what changed.

Backup overview

For those who have never seriously thought about backup

No level of skill can recover data from a failed disk or a stolen laptop. Backups give you a second chance. Whys of backup makes the case: back up, keep your copies offline instead of trusting cloud services, use Borg, and start today rather than procrastinate.

Once you’re convinced, jump to Getting started with Borg: your first encrypted backup and your first restore. Even if you plan to level up to the Borg-simple script later, a wrapper that makes the backup process a breeze, it’s important to know the fundamentals of interacting with Borg first. If you have never opened a terminal, read Terminal basics before anything else; it takes three minutes.

Once you have a working backup on an external drive, read Planning. It covers the decisions the tool won’t make for you: what actually needs backing up, how often, how many copies and where they live, how long to keep them, and how to rehearse a restore so you know the system works before the day it has to.

After you’ve settled the plan, stop typing backup commands. Levelling up: Part 1 sets all of it up once (sources, exclusions, retention, verification) in a config you write once and rarely touch again; from then on you back up with a single command. A backup that takes several steps eventually gets done carelessly. The aim is to spend an hour now so you can spend seconds later. Levelling up: Part 2 beefs up security with GPG, so your passphrase isn’t sitting in plain text.

The rest of the section is reference. Borg-simple script has the whole script that the levelling-up guides set up, so you can copy it. Borg-simple core is a cut-down version with only the core functions, short enough to read through before you decide to trust the script. Manual Borg and GPG is for people who would rather not run a script they did not write; it shows you how to do the same job by running borg and gpg yourself. Borg on an airgapped machine shows how to install Borg on a computer that never touches a network. Creating passphrases explains how to make a strong passphrase and keep it safe. Read it before Borg asks you for one, so you don’t have to make one up on the spot.

Whys of backup

  • For: anyone whose work lives on a computer and who has never set up a backup.
  • Before you start: nothing. This is the argument; the next chapter is the procedure.
  • Reading time: about 8 minutes.
  • You end with: the reasons to back up, to keep the copy offline and off the cloud, and to start today.

TL;DR

Get a working backup running today. Someday a life could depend on it. Borg is the best backup tool so far. Start simple. Iterate later. Don’t put your backups online. Bring your data into a single folder, get a couple of USB sticks, and proceed to the next chapter.

Why backup?

Setting up a reliable backup system is the first thing every serious knowledge worker should do.

Data loss is inevitable

There are two kinds of people, as an old saying goes: those who have lost important data and those who will. Drives fail. Devices get stolen. Mistakes happen. Malware corrupts or locks what you have. The question is not whether you will face data loss, but whether you will be prepared for it.

Recovery is a pain

Recovery without a backup is painful and time-consuming. The asymmetry between the effort of setting up and running a good backup system and the agony of recovering lost data without one is vast. The effort to set up a backup system fades in comparison to the peace that comes from knowing that it’s going to be very hard for you to lose data. Moreover, to keep a backup system going costs almost nothing. The hard part is the one-time work to set it up. This section is meant to reduce the effort, and once you’ve understood the principles, setting up a quality backup could take you less than an hour.

Someone’s life may depend on it

Data is not a luxury. It shapes how you work, how you remember, how you prove who you are and what you have done. For most people, the loss of important data is a serious inconvenience. For some it can be catastrophic: a doctor whose patient records vanish, a journalist whose source files are wiped, a small business owner whose client database disappears overnight. And sometimes, the right file is the difference between life and death: a medical history in an emergency room, the evidence that clears someone, the records that locate a missing person.

It’s a way to honour time

Even if the stakes may not be as high or dramatic, it’s prudent to honour your time. Data represents time, our most finite and non-renewable resource. Every file created, every note written, every photograph you’ve taken is time spent. To be careless with it is to dishonour your time. A backup plan is, among other things, an act of respect for your own labour and time.

Why offline?

Offline backups are the only real defence against malware. Anything your running system can write to, a virus can destroy. Ransomware encrypts every mounted volume it can find. Data loss doesn’t have to come from external attacks. An accidental sync request replicates your deletion faithfully and instantly. But an offline backup, a medium that spends most of its life physically unplugged, is the only defence in such cases.

Why not cloud services?

Offline backups are the responsible thing to do. Don’t upload your data to Google Drive or OneDrive or some other storage service. That’s a bad idea even if it’s encrypted. Take responsibility for what’s yours. Don’t be a wuss.

Who says it’s encrypted?

Most consumer cloud is not end-to-end encrypted at all. “Encrypted at rest” means the provider keeps a key and can read your files whenever it chooses. There is nothing for an attacker to break, because the provider can already decrypt, and so can anyone who breaches them, buys them, or compels them. Even the services that are genuinely end-to-end write and ship the software that does the encrypting. A single update can lift your key, or your files, before they are ever encrypted, and you cannot audit every release. Unless it’s a fully open-source app, not just source-available, don’t trust it.

Your custodian is a moving target

The company can be breached, sold, merged, subpoenaed, served a foreign warrant, or simply shut down. It can change its terms or close your account on a morning you had no reason to expect. Furthermore, providers consolidate, so the entity holding your data next year may not be the one you chose this year. Legal demands on them rise rather than fall. Their incentive to mine what you store, to train models on it, or to sell access to it climbs every quarter.

The stored copy only gets easier to open

“Well what if I encrypt and use cloud only for syncing and a safety net?” Still no. There is the long game you have to consider. An attacker does not have to break your encryption now. He copies your encrypted files out of the cloud today and simply waits. Computers keep getting stronger, and the quantum kind is expected to break the public-key encryption in wide use today. When that day arrives, the attacker unlocks the copy he took years before. Most of the ciphers and hashes in wide use before roughly 2000 have since been broken, weakened, or retired: DES, RC4, MD5 and SHA-1 among them. As recently as July 2026, Anthropic reported attacks that used Claude Mythos Preview to significantly weaken HAWK, a post-quantum digital signature scheme, and to identify a new way to attack round-reduced AES. Now most real-world cryptographic loss came from key sizes ageing out, implementation bugs, protocol design, and deliberate sabotage, not from mathematicians cracking the core algorithm; yet handing over your data means you’re still subject to this attack.

Offline removes the remote attack surface

An offline drive has no remote attack surface, no account to phish, no client to backdoor, no provider to compel, no stored blob to harvest. Your threat model shrinks to the physical: fire, flood, theft, and you deal with physical failures the simple way: keep more than one copy in more than one place. Physical risks do not scale the way network risks do. Breaching one server affects a million people at once, but how does one burgle a million drawers? The chances of an attacker reaching the drive in your home, the drive at your friend’s home, and the drive buried safely in your ancestral home all on the same afternoon are slim. Offline backups give you a boundary you can see, inspect, and defend.

Tradeoffs and solution

Offline drives protect you from data loss only as long as you can reach them. If a fire, a flood, or a tyrannical government means fleeing with what’s in your hands or with nothing at all, the offline drives are useless unless you kept some in different locations or jurisdictions. The drawback here, though, is that these drives are not synced to your latest state. And when the offline drawer of drives isn’t enough, and you want a copy that lives somewhere remote, the solution is still hardware you own: a home server hosted on a machine you control in a different city or country, rather than a company’s cloud. Walk out with the passphrase in your head and the data is still yours.

Why Borg?

Borg is not the only backup-program, but it is a very good one, most likely the best one available today. Don’t take my word for it; do your research, but here are some facts.

Nobody bases a product on a backup engine they do not trust. BorgBase, a hosting service specialised for BorgBackup, offers append-only repositories and two-factor authentication, and it funds development of the surrounding tooling. Hetzner, a German cloud and hosting provider known for affordable, high-performance servers, added native Borg support to its Storage Box product, with an extended SSH service, official documentation, append-only mode, and per-version remote-path pinning. Rsync.net, one of the longest-standing offsite-storage providers, supports Borg natively as well. On the desktop, Pika Backup, a GNOME application, is powered by BorgBackup, and Vorta provides a Qt front-end over the same Borg backend, while Borgmatic wraps it for scheduling and retention policy. A whole ecosystem of CLIs, Docker servers, and web UIs has grown around the engine.

Borg’s headline accomplishment is reach. The main repository carries roughly 14k stars (at the time of this writing). Borg ships as a packaged tool in the Debian, Fedora, and Arch repositories among others, meaning distribution maintainers vetted and adopted it independently rather than leaving it to users. That’s saying a lot. The Open Source Everything list names it as the backup tool. That’s evidence that experts use it rather than just star it.

On reliability it has earned trust the hard way. Borg deliberately reuses the system SSH client and links only OpenSSL’s libcrypto rather than implementing its own network crypto protocol, and it publishes an explicit threat model spelling out that an attacker cannot modify, rename, remove, or add an archive without the client detecting it. It has run in production for about a decade, descending from Attic, which was accepted into Debian in August 2013 before being forked as Borg in 2015, and it is still actively maintained: lead developer Thomas Waldmann and the project run on community funding through GitHub Sponsors and Open Collective. Across that decade it has had one notable cryptographic flaw, CVE-2023-36811, an archive-forgery issue disclosed and fixed in version 1.2.5 with a documented upgrade procedure, which is the responsible-handling record that matters more than any clean-sheet claim.

Borg uses a technique called deduplication, which means, after the first backup, it only stores the changes of the files being backed up, making it suitable for daily, hourly, and even minute-by-minute backups. It supports compression and authenticated encryption, which makes it suitable for storing backups in untrusted locations. To learn more, read the official docs, and verify for yourself if it fits your needs.

Why today?

The perfect backup system is worth striving for, but it’s unlikely you’re going to build it all today (it’s possible though if you follow through with this series of chapters). And the perfect system will always be worse than a working one that’s running today. A single working encrypted backup on an external drive and a passphrase written on paper is much better than the perfect system you get to eventually. Moreover, the perfect backup system is a moving target; the bad guys will never stop trying to find ways to undermine your privacy and sovereignty. And significant improvements in security and resilience do not need the perfect setup; they need a setup that actually runs today. So start simple. Iterate later.

Now the ‘Levelling up’ pages that come later in this series help you set up a darn good system quickly, but they assume you have your data arranged, you’re using Linux, and you understand the principles of a quality backup-system. But if you don’t have a local working backup, it’s important to first learn the basics, the bare essentials, before you move on to a complex system. Knowing the basics hopefully gets you out of trouble when the fancy setup fails.

To proceed in the recommended order, bring your data into a single folder, and use Borg to back it up onto a USB. The next chapter helps you do just that. If you’re not convinced of Borg, use dummy data for now. Get a feel for how easy the backup process with Borg really is; a few simple commands in your terminal and you have an encrypted, compressed backup. Then do your research; and when you’re convinced that Borg is the best, you can repeat the steps with your actual data.

Getting started with Borg

Learn the basics of Borg: install, backup, restore.

  • For: someone who has never used Borg.
  • Before you start: a Linux or Mac machine, a USB stick or a folder to back up to, and the terminal (see Terminal basics).
  • Reading time: about 7 minutes.
  • You end with: Borg installed, one encrypted backup made, and one restore done and checked.

1. Install Borg

Note

OS: Borg runs on Mac and Linux, not Windows directly. Windows users, what are you still doing there? Choosing the OS is the way out. If you really cannot migrate away from Windows, you can run Borg through WSL (Windows Subsystem for Linux): install WSL, then follow the Linux steps below inside it.

Video demo: If you prefer first watching a demo on how Borg works, see the official demo. It’s old but good.

On Debian-based Linux

sudo apt install borgbackup -y

On macOS

Install Homebrew:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

When Homebrew finishes it prints a couple of lines starting with eval or export that tell you how to finish setup; copy those, paste them in, and press Enter. Then install Borg:

brew analytics off
brew install borgbackup

On an airgapped machine

To install on a Debian-based airgapped computer, see this guide.

Whichever route you took, run borg --version; if it prints a version number, you’re ready.

2. Create the repo

Borg calls the backup folder a repository: an encrypted, compressed container that holds your data. You have to do this step only once per drive unless, of course, you lose or damage your drive.

2.1. Get the path to your drive

Plug in your external drive or USB stick, and find the path to it.

Note

On macOS: It appears at /Volumes/your-label. To read the exact path on a Mac, open the drive in Finder, press Cmd + Opt + C to copy it as a pathname, and paste it anywhere; you will see something like /Volumes/your-usb-label; e.g. /Volumes/backup1.

On Linux: It usually appears at /media/your-name/your-usb-label; e.g. /media/john/backup1/.

Tip

If you have no spare drive, you can still test Borg by using a folder on your system, say ~/Documents. But note that the whole point of backing up to a drive is so that if your laptop fails or gets stolen, your backup is not lost with it.

2.2. Name and create the repo

Name the repo anything you like, and run the command to create the repo (after editing the path and repo name to match yours):

borg init --encryption=repokey /Volumes/backup1/borg

On Linux that path should look something like /media/john/backup1/borg. And if you are testing Borg on your system, then it should look something like ~/Documents/borg.

2.3. Enter the passphrase twice

Pick a strong one. For tips on creating a secure passphrase, see Creating passphrases. When it asks whether to show the passphrase for verification, type n. Borg then prints a block that starts IMPORTANT: you will need both KEY AND PASSPHRASE to access this repo!; on Borg 1.2 a paragraph about older Borg versions comes before it. Both are normal, and the repo is ready.

Caution

Write this passphrase on paper and keep it somewhere away from the laptop. Borg needs both the key and the passphrase to open the repo. With repokey the key is stored in the repo itself, so the passphrase is the one you have to keep: lose it, and your backups are gone for good, with no reset.

That’s it. That’s the whole one-time setup with Borg. From here on, unless you lose your drive, it’s just one command per drive to back up (well, two if you count the cd); and two to restore. If you level up and start using scripts, then it’s one command to back up to all your drives; and one command to restore from any one of them. See Levelling up for details.

If you keep a second drive labelled backup2, set it up and back up the same way (borg init --encryption=repokey /Volumes/backup2/borg); just change backup1 to backup2 in the path.

3. Back up

Note

From here, every command below uses /Volumes/backup1/borg; replace it with your own path.

Using the terminal, navigate to the folder that contains the folder you want to back up. Say the folder you want to back up is mystuff and it lives in your Documents folder, run:

cd ~/Documents && borg create -s /Volumes/backup1/borg::{now} mystuff

Borg asks Enter passphrase for key followed by the repo’s path, here and every time it opens the repo; as with sudo, nothing shows while you type. Congratulations. You’ve successfully backed up your folder to your Borg repo. You should see something like this.

Note

If you run create with the full path, ~/Documents/mystuff, Borg backs up the whole path, such that when you extract you get the whole tree home/<user>/Documents/mystuff. While if you cd to the parent and give just the folder’s name, as above, then extract gives you just the folder you backed up. Borg 1.4 and newer can do the same from anywhere with the slashdot hack, ~/Documents/./mystuff; older Borg, which Ubuntu 24.04 and Linux Mint 22 still ship, ignores the /./ without a word.

{now} names this backup with the date and time you make the backup, so each backup stays separate. If you would rather name it yourself, swap {now} for anything; e.g. ::archive. Borg requires archive names within the same repository to be unique. If you try to create a new archive with an existing name, Borg will fail with the error Archive <archive-name> already exists. This is by design because each archive is an immutable snapshot. Those who back up more than one machine commonly use ::{hostname}-{now} so each machine’s backups are labelled and sort together; for one folder on one machine, {now} is all you need.

-s stands for stats, shown in the image above. You may remove that if you don’t care about it.

The first backup copies everything, so it takes a while: roughly two to three minutes for 10 GB to a fast external drive, and longer, ten minutes or more, to a cheap USB stick. After that Borg remembers what it already saved and only adds what changed, so every backup after the first is usually done in seconds to a minute.

Tip

If you do not use the terminal a lot, the next time you want to make a backup, you do not have to retype the command: plug in the drive, wait for it to mount, open the terminal, press the up arrow until the borg create line appears, and press Enter. The cd is on the same line, so it comes back with it; from any other folder Borg wouldn’t find mystuff and would save an empty archive.

Important

Back up after every change you care about. You could also back up on a schedule you will actually keep, once a day or once a week, but definitely before you switch or wipe a laptop.

Keep one of your drives somewhere else: at work, with family, or in a safe. Two drives in the same drawer both die in the same fire or theft; the one stored elsewhere is the one that saves you.

4. Restore

4.1. Select archive

First get the list of archives. From anywhere on the terminal, run the command:

borg list /Volumes/backup1/borg

This produces a list of every backup you have made, with the latest at the bottom. If you used {now} as your archive name, each line starts with a timestamp like 2026-05-09T19:15:31. Usually the latest archive is the one you want; older ones matter when the latest is damaged, or when you deleted or changed a file before your last backup and want it back as it was.

Copy the archive name (which in this case is the timestamp) of the backup you want from the list.

4.2. Extract

Navigate to wherever you want to extract your backup; e.g. Downloads. Then paste your timestamp in place of the one shown:

cd ~/Downloads
borg extract --progress /Volumes/backup1/borg::2026-05-09T19:15:31

--progress first prints Calculating total archive size for the progress indicator, then a running percentage. When it finishes, ls shows your folder, mystuff, in Downloads. Congratulations. And that’s how easy it is to use Borg.

Tip

If you want to extract just one file or folder from the archive, instead of extracting the whole archive, extract only the file using borg extract repo::archive-name path/to/file; for example borg extract --progress /Volumes/backup1/borg::2026-05-09T19:15:31 mystuff/test-file.md. The path is the one inside the archive, so it starts with the folder’s name; borg list /Volumes/backup1/borg::2026-05-09T19:15:31 shows every path.

5. Verify extracts

You’ve learnt how to make a backup and restore it, all good, but that’s not the same as being sure of what the archive holds rather than what you think it holds. Borg will not tell you that. A mistyped path, a folder you meant to include and did not, an exclude that caught more than you intended: each of those produces a clean backup but is quietly missing things. There is no warning, because from Borg’s side nothing went wrong.

So before you delete anything, or start relying on this, spend a few minutes checking. Do it straight after making an archive and extracting, while the source has not changed underneath you. Open the last saved file, and check if the contents are present. To check every file at once, compare the restored folder with the original: diff -r ~/Documents/mystuff ~/Downloads/mystuff. If it prints nothing, every file came back exactly as it was; anything it prints is a file that’s missing or different.

If you have a spare or second machine, restore the backup there; or use a fresh user account, with nothing but the drive and the passphrase. That is the situation you are trying to guard yourself against, yet that is where many find out that the only copy of the passphrase they had was on the machine that died. So don’t skip this step. Test it at least once while nothing is wrong, so you’re confident of restoring from backup on a new system.

When your restore checks out, move on to Planning: the decisions the tool won’t make for you.

Backup planning

  • For: someone whose first backup works and who wants a plan that survives a crisis.
  • Before you start: Getting started with Borg.
  • Reading time: about 10 minutes.
  • You end with: the principles of a sound backup and a plan for what to back up, how often, and where.

Simple in theory

To build a good backup system, you need a plan. The plan is simple in theory: create copies, store them safely, restore after a crisis. But the simplicity of the plan disguises the complexity of execution. Implementation of a good system can actually be crazy complex (though good backup tools make it feel intuitive and simple). It’s one thing to make backups but a whole other thing to successfully restore from them in a crisis.

Principles of a good backup plan

Building a good backup system requires understanding the principles of a quality backup. These are principles, not suggestions. Each one exists for a reason; ignoring one or more of them is why many have failed to restore their data successfully. They are adapted from the seven heads of Ross Williams’ Taobackup, conceived, designed, and implemented in August 1997.

1. Back up impartially

Back up everything. When deciding which files are important enough to protect, remember that data that seems trivial can take hours or days to reconstruct: a configuration file, a licence key, a single bookmark. Such files may be individually minor but collectively irreplaceable. The cost of storing an extra gigabyte can be negligible compared to the cost of recreating lost work.

However, there’s a caveat to backing up everything. If it’s data you never want revealed, maybe don’t back it up. Consider privacy by omission rather than obfuscation. Rather than worrying about securely backing up those compromising pictures, for example, consider not taking or securely deleting those pictures. The strongest privacy is simply never creating or retaining the data in the first place.

2. Back up frequently

Aim to back up at least once a day, or after every significant work session. The more frequently you back up, the less you lose when something goes wrong. But frequency is only as good as the discipline behind it, and both manual and automated approaches have failure modes.

Manual discipline can fail, not from laziness but from life: work gets busy, routines break, and a task that depends on you remembering it will eventually not get done. On the bright side, if you keep at it, actions become habits and eventually second nature.

Automation has its perks, but an updated dependency or a system change can break the process and backups can fail silently; and because you are not in the habit of checking them the way you would a manual run, weeks or months can pass before you discover that nothing was being backed up. Until you understand all the moving parts of an automated system, keep it simple. The manual approach is the more reliable path.

3. Back up resiliently

Keep copies in multiple locations. One backup on the same computer is not a backup; it is a second copy in the same threat environment. A fire, a flood, a theft, a power surge: any of these can take out everything at once. The more copies you have, and the more geographically separated they are, the more resilient your backup system becomes. Two copies in the same building are better than one. Two copies in different cities are better than two in the same building.

Keep at least one copy offline; but aim for more. An online backup is convenient and useful, but any system permanently connected to a network is permanently exposed to network-borne threats. Ransomware, malicious actors, and cloud service failures are all real risks. An offline copy, a drive disconnected from any network when not in use, is immune to these threats. The offline copy is your last line of defence.

4. Back up honourably

Do not delete old backups. The instinct to prune old backups to save space is understandable but dangerous. Damage is not always immediately visible. A virus can quietly corrupt hundreds of files over weeks or months before you notice. By the time you discover the problem, your most recent backups may already contain the corrupted versions. An older backup may be the only path back to clean data. Keep old backups as long as storage allows. When storage runs out, thin the old backups rather than cutting them off: keep recent ones daily and weekly, and one a month for good, so a copy from before the damage survives. Levelling up: Part 1 sets up exactly that rule.

5. Back up cautiously

Test your backups regularly. Belief in your backups is not the same as having backups that work. A drive can fail silently. A backup process can produce incomplete or corrupted archives without any obvious error. A restoration tool can behave differently under pressure than it does during a casual test. Go through the full recovery process periodically, not just the backup step but the restore step. Sit down, simulate a failure, and confirm that you can actually get your data back. A broken backup is no backup at all. And you don’t want to discover that in a crisis.

Executing the plan

With the principles in mind, you’re now better equipped to build.

1. Sort your data

Before you can protect your data, you need to know what you have. Make a list of every dataset in your care. Losing your baby photographs and your client database is a different matter entirely from losing your messaging-app history, and the differences decide how each kind should be handled.

Ask three questions of it. First, how much harm would it do if it were disclosed? Your identity documents, financial records, and medical files are among the most sensitive things you own. What you read, what you write, and who you write to can sometimes reveal more about you than a formal record, and data you hold about other people is more sensitive still, because the harm of exposing it is not yours to discount. This question decides how carefully a thing is locked and where you are willing to keep it.

Second, how badly would you be hurt if it were lost, and could you ever get it back? Your photographs, your notes, and your letters exist nowhere else; lose them and they are gone. A film collection or your installed software can be downloaded again. The data that needs the most copies, kept in the most places, is the kind you could never recover, not simply the kind you use most. The question sorts your data into three tiers:

  • High: data you could never recover and whose loss would be devastating and irreversible; the photographs, records, and work that exist nowhere else.
  • Medium: data whose loss would be costly and disruptive but survivable with real effort.
  • Low: data whose loss would be a minor inconvenience, including anything you could simply download or reinstall. If you have storage constraints, movies or music files, however much you enjoy them and however often you open them, usually fall in this category, not because they do not matter to you but because losing them costs you a download, not the thing itself.

Third, how often does it change? Files made once and rarely touched (scanned certificates, old photographs, etc.) need backing up once and then only protecting; saved correctly, they stay saved. Files you change constantly, working notes, project documents, client files, are harder, because any copy may already be out of date, and a copy of a working file is only useful if the original is sound. This question decides how often a backup runs and how much past history you keep.

The three axes are independent: a thing can be highly sensitive yet easy to replace, or irreplaceable yet harmless if seen. So answer all three rather than collapsing them into one.

Be thorough. It is easy to account for obvious items like project files and photographs, but easy to overlook others: browser bookmarks, application settings, data stored inside cloud services, files maintained by employees or collaborators, and licence keys. These are often the hardest things to reconstruct precisely because they live inside systems rather than in visible folders. Mobile devices are a commonly missed category. Phones hold contacts, messages, photographs, authenticator apps, and application data that most people never think of as data requiring a backup plan. A lost or destroyed phone without a backup can mean losing irreplaceable conversations, years of photos, and the very two-factor codes needed to access accounts on the replacement device. Include your phone in your inventory and make sure it has a backup path.

Do all of this for whole groups of files, not file by file. You are sorting your life into a handful of boxes, not labelling every sheet of paper inside them. Give each box the care its most demanding contents need, and split a box in two only when one part of it genuinely needs different treatment from the rest. You do not need to get this perfect, and you can change it later; a rough sorting you actually finish is worth far more than a flawless scheme you never start.

2. Take note of the special category

One category sits outside the three axes entirely. Your keys and secrets, SSH and GPG keys, two-factor seeds, wallet phrases, are not ordinary data that you merely lose or reveal; they are the keys to everything else. A leak cannot be undone and a loss has no recovery path, so they are kept and protected separately, offline, with more care than any other data you own.

3. Make folders

Once your list is made and each group has its three answers, you can turn those answers into something practical: a small set of folders. Do not try to make a folder for all three questions at once. A file can live in only one folder, the same way a sheet of paper can sit in only one box, so trying to sort by sensitivity and value and change all at the same time only ties you in knots. You pick one question to build your folders around, and you let the other two become simple decisions you make about each folder rather than more folders inside it.

Making folders around the second question (could I get this back if I lost it?) gives you the simplest structure: three folders at the top level. One holds the things that are truly yours and exist nowhere else, your photographs, documents, notes, and letters; inside it you will still keep your ordinary everyday folders, and the box simply keeps the irreplaceable things together so they are easy to protect well. A second holds the things you could download or reinstall, your films, music, software, and books. And a third, kept separately and with the most care, holds your keys and secrets, for the reasons above. That is the whole structure.

If you’ve picked the simplest structure, the other two questions do not become folders; they become two decisions you make about each folder you have just made.

The first decision is how carefully to lock it, and where you are willing to keep it. Your most sensitive material, financial records, medical files, anything concerning other people, anything that would do real harm if a stranger read it, gets the strongest protection, and the most sensitive of it is also what you should be least willing to hand to a custodian you do not control. If only part of a folder is that sensitive, you can give that part its own protected sub-folder rather than over-protecting everything else. The rest can be protected more lightly.

The second decision is how often to back it up. The material you change most, the notes and projects you touch most days, gets backed up most often, because that is where you have the most to lose between one backup and the next. The material that sits still, the photo archive and the scanned certificates you never alter, needs backing up far less often; once it is safely copied, it stays safe. Notice that “how often it changes” never became a folder; it became a schedule.

To see all of this in action, consider an ordinary inventory. Say you have a photo archive, scanned certificates, daily project notes that include client material, a film library, and a wallet seed. The photo archive is the obvious case: irreplaceable, rarely changed, so High, protected well, and backed up only now and then. The certificates make the independence of the axes sharp, sitting in that same top tier yet earning the lowest backup frequency you have, because how much something matters and how often it changes are different questions. The film library is the same point from the other side: the thing you open most, yet Low, because losing it costs a download, not the thing itself. The project notes exercise all three axes at once, High because they are irreplaceable, backed up most often because they change daily, and holding client material so sensitive that it gets its own tightly locked sub-folder rather than over-locking the rest. The wallet seed never enters this scheme at all; it goes to secrets, offline. The result is a layout you could draw on a single sheet:

~/
├── irreplaceable/       # High tier: exists nowhere else
│   ├── photos/          # rarely changes  -> backed up now and then
│   ├── certificates/    # never changes   -> lowest frequency of all
│   └── projects/        # changes daily   -> highest frequency
│       └── clients/     # most sensitive  -> own tightly locked sub-folder
└── replaceable/         # Low tier: re-downloadable, backed up rarely
    ├── films/
    ├── music/
    └── software/

# secrets (wallet seed, SSH/GPG keys) are deliberately NOT on this tree:
# kept offline and separate, protected more carefully than anything here

4. Pick your storage

You need physical drives that you own and control. Cloud storage is not a substitute. This topic has been addressed in Whys of backup. As for how many drives you need, a well-regarded framework for thinking about storage is the 3-2-1 rule: keep three copies of your important data, on at least two different storage devices, with at least one copy kept off-site. Two external drives stored in different places, plus the original on your computer, satisfy this standard. The goal is to ensure that no single event can reach all your copies at once. The best long-term strategy, however, is to keep all your backups offline. Strong encryption isn’t enough for maximum privacy and security. Where your data resides is also important.

5. Choose your backup tool

Research the full landscape when you have a stable, working system; do not let the research phase become an indefinite delay. If you’re unsure, pick BorgBackup. Borg runs well on Linux and macOS with no dependency complications, and borgmatic makes scheduling and retention policy straightforward to configure, if you want a third-party wrapper; this site’s own path is the Borg-simple script, set up in the next chapter. You’ll need a passphrase when you create each repository; see Creating passphrases.

Next: Levelling up: Part 1, where the plan becomes a config and a single command.

Levelling up: Part 1

  • For: Linux users who want encrypted backups to several USB drives from one command.
  • Before you start: Planning, and the “What you need” list below.
  • Reading time: about 24 minutes; the setup itself takes about fifteen.
  • You end with: versioned, encrypted backups of the folders you name on every drive you name, run by one command, with the option of a schedule.

This is the first of two guides to borg-simple, a shell script that wraps BorgBackup. The command you type is backup. It makes encrypted, versioned backups of the folders you name, to every USB drive you name, from one command.

This part sets everything up with your passphrases in a plain text file that only you can read. That is the simplest arrangement, it is the only one that works unattended from cron (automated backups), and it is where you should start.

Part 2 encrypts that file with GPG, which has stronger security at rest, but it costs you the ability to run automated backups with a timer.

The last section of this guide, Doing all of this without the script, gives the borg command for each thing the tool does. The script is a convenience, not a dependency: your backups are ordinary borg repositories, readable by ordinary borg, on any machine, forever.

What you need

  • A Linux machine. These were written on Devuan; Debian, Ubuntu, Mint and their relatives all work the same way.
  • One or more USB drives. Two is comfortable, three is generous, four is careful. They do not have to match in size or brand.
  • The backup script, saved somewhere you can find it.
  • About fifteen minutes.

Terminology

You will see these words in every message the tool prints, so it is worth a minute now.

Drive label. When you plug a USB drive in, your system mounts it, which means it makes the contents appear at a path. On most desktop Linux that path is /media/your-name/something. That last part is the drive label. It is usually the name you gave the drive when you formatted it. d1, d2, d3 are good labels because they are short and you will type them.

Repository, or repo. A folder on the drive where borg keeps backups. Think of it as a vault with one lock. Everything inside one repo shares one passphrase.

Archive. One snapshot inside a repo. Every time you run backup, each repo gets a new archive, named with the date and time. Yesterday’s archive does not go away when today’s is written, which is what “versioned” means.

Deduplication. Borg splits your files into chunks and stores each distinct chunk once. A second archive of a folder you barely touched costs almost nothing. This is why you can keep months of daily snapshots on a drive that is not much bigger than the data.

Retention, or pruning. Deleting old archives on a rule, so the repo does not grow forever. “Keep seven daily, four weekly, every monthly archive” is a recommended retention rule.

Passphrase. The secret that opens one repo. Borg encrypts everything with it. Lose it and the backup is a pile of noise to you or anyone who steals the drive.

Why not just type borg commands

Borg is a good program, and you can drive it by hand; the last section shows you how. With the script, this is what you stop doing.

  1. You don’t have to install borg first; if it is missing, the first command that needs it installs borgbackup for you.
  2. You don’t have to run a command per folder; backup archives every repo you configured.
  3. You don’t have to rerun the command per drive; each repo goes to all of its drives in the same run.
  4. You don’t have to remember which folder belongs in which repo, or which repo lives on which drive; you wrote it down once.
  5. You don’t have to type a passphrase per repo per drive; it is handed to borg automatically.
  6. You don’t have to leave a passphrase in your shell history or in an exported variable; a short-lived helper process passes it straight to borg and exits.
  7. You don’t have to assemble the repository path out of the mount point, the drive label and the repo name.
  8. You don’t have to abandon the run because one drive is not plugged in; the missing drive is reported and the others still get their backup.
  9. You don’t have to spell out --pattern rules on the command line; your excludes live in the config file.
  10. You don’t have to know that borg matches those rules against the source path rather than against the name a folder is stored under, which is the difference between an exclude that works and one that silently does nothing.
  11. You don’t have to remember borg’s /./ trick, the one that makes a folder land at the top of the archive under its own name no matter where it lives on disk, nor that it silently does nothing before borg 1.4.
  12. You don’t have to run borg prune as a second command; retention runs straight after the archive, for every repo that has a keep line.
  13. You don’t have to remember that pruning alone frees nothing, and that borg compact is what actually returns the space.
  14. You don’t have to interpret borg’s exit codes; a file that changed mid-copy is reported as a warning on a successful run, and a real error is reported as a failure.
  15. You don’t have to add up sizes; the run tells you how much it added and how large the repo is now, counting the same data once even when it went to four drives.
  16. You don’t have to reconstruct what happened from a screen of output; the run ends in one line that counts repos by outcome and stamps the time.
  17. You don’t have to run borg init once per repo per drive; backup init creates all of them.
  18. You don’t have to write a new repo’s config block by hand either; backup init <repo> <path>... writes the block, asks for the passphrase, stores it, and creates the repo on every drive.
  19. You don’t have to look up an archive’s name to restore it; backup extract <repo> takes the newest.
  20. You don’t have to count backwards through a listing to reach an older one; -3 means the third newest on that drive.
  21. You don’t have to cd into the right parent directory before restoring; -i puts each folder back where it came from.
  22. You don’t have to restore repos one at a time; backup extract with no repo named does every one it can reach.
  23. You don’t have to own a config file at all to restore; point extract at a repo path on the drive and borg asks for the passphrase itself.
  24. You don’t have to invoke borg once per repo per drive to see what you have; backup check walks every mounted drive.
  25. You don’t have to rotate a passphrase on each drive separately and then remember to update your notes; backup pass-change rotates on every drive and rewrites the passphrase file.
  26. You don’t have to mv a repo directory on four drives and then edit two files; backup rename does the directories, the config and the passphrase file, or refuses and changes nothing.
  27. You don’t have to make sure two backups never overlap; the tool takes a lock and the second one says so and stops.
  28. You don’t have to remember to lock down the passphrase file; the tool tightens one that anybody else on the machine can open, and tells you when what was in it may already have been read.
  29. You don’t have to worry about a debug session printing a passphrase into a log; the tool refuses to run under bash -x at all.
  30. You don’t have to notice on your own that a mistyped filter backed up nothing; an archive that comes out empty is called out, and a repo whose allowlist matches everything is refused before anything is written.
  31. You don’t have to take a stranger’s word for any of this; every command it runs is in the last section, and you can do the whole job yourself.

Numbers 5, 6, 10, 28 and 29 are the reason the file layout below is worth following exactly. The rest is convenience.

The two files you keep

Everything you configure lives in two files in your home directory. Nothing else is stored anywhere.

The config file, always at ~/.borg-config. It answers three questions: where do drives appear, what folders go into which repo, and which drives does that repo live on. The path is fixed. The tool does not take a --config flag and does not read one from anywhere else, so a config that is not at ~/.borg-config is not in use.

The passphrase file, wherever you point PASSPHRASE_PATH. It holds one line per repo, and nothing else:

set_pass documents 'a long passphrase for the documents repo'
set_pass photos    'a different long passphrase for photos'

Give it any name you like. The tool works out whether it is plain text or GPG-encrypted by reading the file itself, not by looking at the name, so ~/.borg-pass is as good as ~/.borg-pass.gpg. Part 2 uses that: you encrypt this file in place and change nothing else.

Two constraints on the path. It must be readable and writable by you alone; the tool tightens a file that grants any access to group or other back to 600 and warns you, and refuses one that someone else owns or that is a symlink. And it must contain no spaces, quotes or backslashes, because borg runs the command that fetches your passphrase without a shell to unpick them.

In this guide, the file is plain text. Its only protection is its permissions and whatever disk encryption you have. Not a big deal but it is a real risk, and the subject of Part 2, and it is also the only arrangement that can run without you present.

Setup

Five steps, in order.

Step 1: save the script and run it once

Copy the whole backup script. Open a text editor, paste it in, and save it somewhere you will not lose it. ~/Documents/backup.sh is a good choice, and it is the one the installer’s examples use later.

Pick a folder whose path has no spaces, quotes or backslashes in it. The script re-invokes itself by absolute path to fetch a passphrase, and borg runs that without a shell, so a path like ~/My Documents/backup.sh will stop the tool with an error the first time it needs a passphrase.

Mark it runnable:

chmod +x ~/Documents/backup.sh

Then check that it works:

bash ~/Documents/backup.sh help

You should get a list of commands.

That bash ~/Documents/backup.sh is how you run it until you install it properly, which is a section near the end of this guide. For the rest of this guide, commands are written the short way, as backup init and backup check. Until you have installed it, type bash ~/Documents/backup.sh init and so on instead.

You do not need to install borg. The first command that needs it will run sudo apt-get install borgbackup for you and ask for your password. If you would rather do it yourself, or you are not on a Debian-family system, install it now:

sudo apt install borgbackup

Then check which version you got:

borg --version

It has to be 1.4 or newer, and below 2.0. The tool checks this itself on every command that uses borg and stops if it is outside that range, so you will find out immediately rather than at the worst possible moment. Debian trixie, Devuan Excalibur, Ubuntu 26.04 and their relatives ship 1.4. Debian bookworm and Devuan Daedalus ship 1.2.4, which is too old; bookworm-backports has 1.4 if you cannot move the whole machine. Ubuntu 24.04 and Linux Mint 22 ship 1.2.8, also too old. There, remove it with sudo apt remove borgbackup and install Borg’s own build the way Borg on an airgapped machine does, minus the USB stick; if you will run backups from cron, also put PATH=/usr/local/bin:/usr/bin:/bin on the first line of your crontab, because cron does not look in /usr/local/bin.

This matters more than a version requirement usually does, so it is worth one paragraph. Borg 1.4 is the first version that understands the trick this tool uses to put a folder at the top of an archive under its own name. On 1.2 that trick is ignored without a word, every file is filed under its full path instead, and nothing at all goes wrong until the day you restore and get a nest of empty directories with your folder at the bottom. The full explanation is in the last section.

Step 2: find your drive labels

Plug in every drive you intend to back up to, and wait a few seconds. Then:

ls /media/$USER

You will see one entry per drive:

d1   d2

or, if you never renamed them:

MY-BACKUP   SEAGATE-2TB

Write these down. They go in the config in step 4. Short names are easier to live with; you can rename a drive in your file manager if you want to.

The directory those labels sit in, /media/your-name, is the mount base. If your system puts drives somewhere else, note that path instead.

Step 3: write the passphrase file

Pick a passphrase for each repo you are about to create. Make them long. A passphrase you cannot remember is fine here, because you are about to write it down; a short one is not.

Create the file:

touch ~/.borg-pass
chmod 600 ~/.borg-pass

Then open it in a text editor and put one line per repo in it:

set_pass documents 'a long passphrase for the documents repo'
set_pass photos    'a different long passphrase for photos'

Single quotes around the passphrase. If the passphrase itself contains a single quote, write it as '\''.

You must write the first line by hand. Later on, backup init <repo> <path>... will prompt you for a new repo’s passphrase and append it here for you, but it will only do that if this file already exists.

The chmod 600 is not decoration. Without it, anyone on the machine can read the passphrases until the tool’s first run tightens the file, and the tool then tells you to rotate them.

Step 4: write the config

Create ~/.borg-config with the template below, then chmod 600 ~/.borg-config.

# ~/.borg-config   (chmod 600)

# ── topology: where the drives mount, and where things go ──────────────────
MOUNT_BASE="/media/john"                # each drive is MOUNT_BASE/<label>
ALL_DRIVES="d1 d2 d3 d4"                # the drive pool; `backup_drives all` expands to these
REPO_SUBDIR=""                          # optional folder between the drive and the repo ("" = repo at the drive root)
RESTORE_PATH="$HOME/Downloads"          # where `extract` drops restored folders
PASSPHRASE_PATH="$HOME/.borg-pass"      # the one passphrase file; "" lets borg prompt for every repo
SRC_HOME=""                             # in-place restore only: a foreign home prefix to remap onto yours

# ── one block per repo: repo_name opens a block, the lines under it configure it ──
repo_name documents
backup_data "$HOME/Documents" "$HOME/Notes"
backup_drives all
keep daily=7 weekly=4 monthly=-1
exclude "*.tmp" "**/cache/**"
compression auto,zstd
# restore_to "$HOME/Notes" "/mnt/spare"  # optional: send one folder somewhere other than RESTORE_PATH

repo_name photos
backup_data "$HOME/Pictures"
backup_drives d1 d2
keep last=20

Step 5: create the repos and take the first backup

Plug in the drives and create the repositories:

backup init

That walks every repo in the config, creates it on each of its mounted drives, and skips the ones that already exist. Then:

backup

The first run is the slow one, because nothing has been seen before. Every run after it only stores what changed.

What to edit, and why

Of the six settings at the top, two are yours and four can usually stay as they are.

MOUNT_BASE is the directory your drives appear in, from step 2. Get this wrong and the tool reports that no drives are mounted, because it is looking in the wrong place.

ALL_DRIVES is the list of labels from step 2. It exists so you write your drives down once; a repo that says backup_drives all gets this whole list, so adding a fourth drive is one edit rather than one per repo.

REPO_SUBDIR is for people who keep their repos inside a folder on the drive rather than at its top. If /media/john/d1/borg/photos is where your repo goes, this is borg. Empty means /media/john/d1/photos. It applies to every drive; the tool cannot handle a subfolder on some drives and not others.

RESTORE_PATH is where a restore drops its files, under a directory named after the repo. It defaults to your Downloads folder, which is a good place for it, because a restore should land somewhere you can inspect before you trust it.

PASSPHRASE_PATH points at the file from step 3. Leave it empty and borg will prompt you for every repo on every drive, which defeats most of the list above.

SRC_HOME is for one situation only: restoring in place on a machine where your username changed. If the archives were made under /home/john and you are now /home/jane, set it to /home/john and an in-place restore will redirect to your home. Leave it empty otherwise.

Below the settings, a repo_name line opens a block and every line under it belongs to that repo until the next repo_name. To stop backing something up, comment out or delete its whole block. Comment out the repo_name line alone and the tool will stop and tell you, rather than quietly attach the orphaned lines to the repo above.

The directives inside a repo block

backup_data <path>... lists the folders this repo archives. Each is stored at the top of the archive under its own basename, so two folders in one repo may not share a name. You may leave it out; a repo with drives but no folders is legal, is reported as “awaiting data”, and archives nothing until you give it some.

backup_drives <label>... lists the drives this repo is written to. The single word all expands to ALL_DRIVES.

keep <key=N>... sets retention. The keys are last, hourly, daily, weekly, monthly, yearly. N is a whole number, or -1 to keep that tier forever. A repo with no keep line is never pruned, which is the safe default and also the one that fills a drive. Unset keys on a line that exists default to daily=7 weekly=4 monthly=-1 yearly=-1 and zero for the rest. A nonzero last=N keeps the N newest archives and ignores the other keys.

exclude <glob>... drops matching paths. A bare glob applies anywhere inside any of this repo’s folders, so exclude "*.tmp" catches a .tmp file at the top of a folder and one six levels down alike. * stays inside one path segment and **/ crosses directories, so **/cache/** and *.tmp are both fine and mean what they look like. A glob written as an absolute path, like /home/john/Documents/scratch, names that one place on disk instead.

include_only <glob>... is the opposite: keep only what matches in this repo, drop everything else.

include_only_in <folder> <glob>... does the same for one backup_data folder and leaves the repo’s other folders whole. These are two directives, not one with an optional first argument; writing a folder after include_only is refused with a message naming the replacement. An exclude always wins over an include_only, so a secret you exclude stays out even if an allowlist would have kept it. Before writing an archive for a repo with an allowlist, the tool asks borg what that allowlist actually does and refuses the run if it drops nothing or keeps nothing, because an allowlist that quietly matches everything is a folder going to your drive whole.

restore_to <folder> <parent path> sends one folder somewhere other than the run’s restore directory. The path names the parent the folder arrives in, the same way -i does, so a folder at ~/Notes given /mnt/spare arrives at /mnt/spare/Notes. Give it the parent, not the destination: /mnt/spare/Notes would put the folder at /mnt/spare/Notes/Notes. Folders with no line fall back to the restore directory for that run. It cannot name the folder’s own parent; that is what -i is for.

compression <spec> is passed to borg untouched, for example auto,zstd,10. Leave it out for borg’s default.

archive no takes the repo out of the backup run while leaving init, check, extract and pass-change working on it. Absent means yes.

If you have an old config using backup yes|no or enabled yes|no, those were renamed to archive, and the tool will stop and tell you rather than quietly ignore the line.

Everyday commands

backup                         back up every repo
backup <repo>                  back up one repo
backup check                   deep-verify every repo on every mounted drive
backup list <repo> <drive>     that copy's archives, newest first
backup extract <repo>          restore the newest archive under RESTORE_PATH
backup extract <repo> d1 -2    restore the second newest on that drive
backup extract <repo> -i       restore in place, over the originals
backup extract                 restore every repo it can reach
backup init <repo> <path>...   create a new repo, block and passphrase and all
backup claim <drive>           take ownership of a drive whose files are another user's
backup pass-change <repo>      rotate a repo's passphrase on every drive
backup rename <old> <new>      rename a repo everywhere it exists
backup version                 which version of the script you are running
backup help                    the list

extract counts archives per drive, so -2 and lower need you to name which drive to count on. -1, the newest, is the default and does not.

pass-change and rename need every one of the repo’s drives plugged in, and refuse to start otherwise. Half a rotation across four drives is worse than no rotation, so they check first and change nothing if they cannot finish.

extract <repo> without -i writes the repo’s folders straight into RESTORE_PATH, under the same names the archive holds, so a repo whose folder is ~/Documents lands at RESTORE_PATH/Documents. It asks first if a name it is about to write is already there, and passing -y accepts that.

Bare extract, the whole-suite form, is different: it gives each repo its own RESTORE_PATH/<repo> directory, because restoring every repo into one directory would merge folders that share a name across repos. That directory belongs to the restore, so it asks about anything already in it, colliding or not. Worth knowing why, since it is the weaker of the two: borg overwrites what the archive holds and leaves everything else alone, so restoring into a directory that already has an older restore in it silently mixes two archives, and every file you deleted or renamed since then sits there looking like live data. Restoring a named repo into RESTORE_PATH cannot be protected that way, because that directory is yours and is never empty, so only name collisions are flagged.

extract -i overwrites the originals, asks you to confirm, and fails rather than proceed if there is no terminal to ask at. Each folder goes back to its own original parent, so a repo holding ~/Documents and /srv/notes puts one in ~ and the other in /srv.

Typing backup instead of bash ~/Documents/backup.sh

Everything above works with the long form. This makes it short, and it is also what the schedule section below needs.

Linux looks for commands in the directories listed in your PATH, and ~/.local/bin is the conventional place for your own. Copy the script there under the name you want to type:

mkdir -p ~/.local/bin
cp ~/Documents/backup.sh ~/.local/bin/backup
chmod +x ~/.local/bin/backup

Open a new terminal and try backup help from any directory.

If your shell cannot find it, ~/.local/bin is not on your PATH. Add this line to the end of ~/.bashrc, then open a new terminal:

export PATH="$HOME/.local/bin:$PATH"

The copy in ~/.local/bin is the one that runs. Editing ~/Documents/backup.sh afterwards changes nothing until you copy it over again, which is the usual way people end up debugging a version they are not running.

If you want something shorter still, an alias in ~/.bashrc is the place for it, not a second copy of the script:

alias bkp='backup'

Aliases work at your keyboard only. Cron and desktop launchers do not see them, which is why the schedule below uses the full path.

Running it on a schedule

This is the part that only works here, in Part 1. Cron runs when you are not there, so nothing may stop to ask you anything, and a plain text passphrase file is the only kind that never does. Once you move to the GPG file in Part 2, unattended runs stop being dependable.

Two things cron cannot do for you. It cannot plug a drive in, and it cannot mount one. So a scheduled backup makes sense for a drive that lives in the machine or stays plugged in, and a drive you carry is still a drive you run backup for by hand.

Open your crontab with crontab -e and add:

30 20 * * * /home/john/.local/bin/backup >> /home/john/.borg-backup.log 2>&1

Use the full path. Cron’s PATH is nearly empty and will not find backup on its own.

Redirect both streams to a log. Without the redirect, cron mails you the output, and on a desktop machine with no mail set up that means it goes nowhere and you never learn that four weeks of backups failed. Check the log now and then; the last line of each run says what happened.

You do not need to guard against overlap. The tool takes an exclusive lock at the start of a run, so if a long backup is still going when the next one fires, the second says another operation is in progress and exits.

A drive that is not mounted at 20:30 is not an error that stops the run. It is reported, the repos on the other drives still get archived, and the run finishes with a nonzero exit code so your log shows it was not a clean night.

Starting over on a new machine

This is the whole reason for the exercise, so it is worth reading before you need it.

Your two files, ~/.borg-config and your passphrase file, are inside the backups. Make sure of that now: at least one repo’s backup_data must cover them, either by backing up your home directory or by putting both in a folder that a repo covers. A backup you cannot open is not a backup.

On the new machine:

Copy the backup script over, from a USB stick, a repo you can clone, or a printout if it comes to that. Save it as in step 1; you can install it onto your PATH later, once things are calm.

Plug in one of your drives and look at what is on it:

ls /media/$USER/d1

Restore the repo that holds your two files, by path, with no config anywhere:

cd ~
bash ~/Documents/backup.sh extract /media/john/d1/documents

Any argument containing a slash is treated as a repo path. The tool skips its config entirely, borg asks you for that repo’s passphrase, and the archive is unpacked into the directory you are standing in. This is the step that needs nothing but the drive and the passphrase in your head or on paper, which is why the passphrase for at least one repo has to be recoverable from outside the backups.

You can do this step with borg alone and no script at all; see the last section.

Now put the two files where they belong. The config path is fixed, so it has to be moved rather than pointed at:

cp ~/Documents/.borg-config ~/.borg-config
chmod 600 ~/.borg-config
cp ~/Documents/.borg-pass ~/.borg-pass
chmod 600 ~/.borg-pass

Open ~/.borg-config and check three things against the new machine. MOUNT_BASE still has your username in it if you wrote it out in full, and the new machine’s username may differ. PASSPHRASE_PATH must point at where you just put the passphrase file. ALL_DRIVES should still match your labels, which it will unless you replaced a drive.

From here everything is normal:

backup check
backup extract

If your username changed and you want folders back at their original paths, set SRC_HOME to the old home directory and use backup extract -i.

If the drive’s files belong to the old machine’s user account, borg cannot write its lock and the tool reads with the lock bypassed and warns. backup claim <drive> takes ownership of the repo directories on that drive, and only those.

Doing all of this without the script

Everything above is borg underneath. This section is the whole tool in plain borg commands, so you can check what is being done on your behalf, work on a machine where the script is not installed, or decide you would rather not run it at all.

Assume one repo called documents, holding ~/Documents and ~/Notes, on a drive labelled d1.

Set the repo and the passphrase for the session.

export BORG_REPO=/media/john/d1/documents

Then let borg prompt you for the passphrase each time, which is the safe default. Do not export BORG_PASSPHRASE: an exported variable is readable through /proc by anything running as you, and it lands in your shell history.

Create the repo.

borg init --encryption=repokey

Once per repo per drive. Four drives means four borg init calls with four different BORG_REPO values, all with the same passphrase if you want the script’s arrangement.

Take an archive.

borg create --stats --compression auto,zstd \
    "::{now}" \
    /home/john/./Documents \
    /home/john/./Notes

The /./ in the middle of each path is the whole trick behind the tool’s archive layout. It tells borg to store the folder under everything after the dot, so Documents/... rather than home/john/Documents/.... Without it a restore recreates the entire path underneath wherever you unpack. It needs borg 1.4 or newer; on older borg it is ignored silently and you get the deep path.

Exclude things.

borg create "::{now}" \
    --pattern '- sh:home/john/Documents/**/*.tmp' \
    --pattern '- sh:home/john/Notes/**/*.tmp' \
    /home/john/./Documents /home/john/./Notes

This is the part most worth reading twice, because it is the one that fails silently. Borg matches these patterns against the source path with its leading slash removed, never against the name the folder is stored under. A folder given as /home/john/Documents reaches the matcher as home/john/Documents/..., and /./ changes only what is stored, not what is matched. So - sh:Documents/**/*.tmp matches nothing at all, and the run reports success while archiving every one of those files. **/ matches zero directories as well as many, which is why one pattern per folder covers every depth including the top. Check any pattern before you trust it:

borg create --dry-run --list "::check" --pattern '- sh:...' /home/john/./Documents

Lines beginning with x were excluded, lines beginning with - would have been archived.

Prune, then compact.

borg prune --keep-daily 7 --keep-weekly 4 --keep-monthly -1
borg compact

Pruning marks archives for removal; it does not return the space on its own. borg compact is what does, and it is a separate command by design.

Verify.

borg check --verify-data

Slow, because it reads and re-hashes everything. This is the command that tells you a drive is going bad before you need it.

See what you have.

borg list
borg list "::2026-08-17T20:30:00"

The first lists archives, the second lists the files inside one.

Restore the newest archive.

mkdir -p ~/Downloads/documents
cd ~/Downloads/documents
borg extract "::$(borg list --last 1 --short)"

Borg always extracts into the current directory; there is no destination flag. That is why the script cds for you, and why restoring “in place” is nothing more than cding to the folder’s original parent first:

cd /home/john
borg extract "::$(borg list --last 1 --short)" Documents

The trailing Documents restores that one folder out of the archive. Restore into an empty directory unless you mean to merge; borg overwrites what the archive holds and leaves everything else where it is.

Restore an older one.

borg list --last 5 --short
borg extract "::the-name-you-picked"

Read a drive that belongs to another user, or is read-only.

borg list --bypass-lock
borg extract --bypass-lock "::archive-name"

Borg wants to write a lock file even to read. When it cannot, this skips the lock. Only use it when you are certain nothing else is writing that repo.

Change a passphrase.

borg key change-passphrase

Once per drive. Every copy of a repo has its own key, so a rotation that only reaches three of four drives leaves the fourth on the old passphrase.

What you are giving up by doing it this way. The commands above are all of it, and they are not hard. What the script adds is that it runs them for every repo and every drive from one invocation, never puts a passphrase on a command line or in an environment variable, gets the pattern anchoring right, does not stop because one drive is missing, refuses a filter that is not doing what you think, and tells you at the end what actually happened. None of that is magic, and none of it is required. The repositories are ordinary borg repositories either way, and any borg on any machine can open them.

When something looks wrong

borg not found; installing borgbackup... Expected on a fresh machine. It will ask for your password and carry on.

borg <version> is too old or borg <version> is too new The installed borg is outside the 1.4-to-below-2.0 range. See step 1. If it says too new, check whether borgbackup-is-borgbackup2 is installed; that package repoints the borg command at the 2.x line.

<file> was mode N, reachable by group or other; tightened it to 600 The config or the passphrase file could be opened by someone else, and the tool has closed it. If the next line says it was readable by them until now, rotate with backup pass-change <repo>. If the message says the file is a symlink or not owned by you, nothing was changed; fix that file by hand.

MOUNT_BASE '<path>' is not a directory MOUNT_BASE is wrong. Check it with ls /media/$USER.

no drives mounted for the repo(s) to back up (looked for: ...) None of the drives those repos want is plugged in and mounted. Note that this is checked before anything reads your passphrase file.

could not read passphrases from <file> The file has no valid set_pass lines, or, once you are on Part 2, it cannot be decrypted right now.

no passphrase set for <repo>; skipped this run That repo has no set_pass line. Add one, or let backup init <repo> store it.

no passphrase file at <file> yet; create it with your first repo's set_pass line init will add lines to an existing file but will not create the file. Do step 3.

passcommand needs paths free of whitespace, quotes, and backslashes The script, or the passphrase file, is saved somewhere with a space or a quote in the path. Move it somewhere plainer and update PASSPHRASE_PATH if that is what moved.

config: ignoring unknown config directive '<name>' A directive the tool does not have, usually a typo or a line left over from an older version, such as backup yes or enabled yes where archive yes is meant. It is only a warning, so the run continues without whatever that line was supposed to do; fix it, because a mistyped backup_data drops a folder from your backup this way.

<repo> has an allowlist but it dropped nothing An include_only or include_only_in line is matching no path borg sees, so the folder would have gone to the drive whole. Check it with borg create --list --dry-run, and remember that borg matches the source path, not the stored name.

archived <repo> on <drive> but the archive is EMPTY The opposite mistake: a pattern matched everything, so nothing was kept.

<dest> already contains: ... A name this restore is about to write is already there. Move it, or pass -y to overwrite it.

another backup operation is in progress A previous run has not finished. Wait for it.

refusing to run under bash -x Tracing would print your passphrase. Debug a specific section instead.

Where to go next

Part 2 encrypts the passphrase file with GPG, so the passphrases are not sitting in readable text on your disk. Read it before you decide, because it costs you unattended backups and it introduces a key you can lose.

Manual Borg and GPG goes further than the section above, one borg command at a time, with the GPG side as well.

Borg-simple core is a much shorter script that reads the same ~/.borg-config and the same passphrase file, and does two things: back up every repo, and extract one repo from one drive into the current directory. It exists for the day the main script will not run, or you want to read the whole thing before trusting it. It ignores keep, restore_to and the allowlist directives, and skips any repo that has an allowlist rather than guess.

Levelling up: Part 2

  • For: Part 1 users who want the passphrase file encrypted at rest.
  • Before you start: Part 1 working.
  • Reading time: about 10 minutes.
  • You end with: a GPG-encrypted passphrase file, and no more unattended backups.

Part 1 left you with working backups and one weak point: the passphrases to every repo are sitting in a readable file in your home directory. This part encrypts that file with GPG.

Read What it costs you and The danger, stated plainly before you start. This rung costs you unattended backups, and it hands you a key that you can lose in a way that destroys every backup you own. It is worth doing anyway, for most people, most of the time.

Doing this without the script, near the end, is the same arrangement in plain gpg and borg commands.

What you are actually fixing

In Part 1, ~/.borg-pass is protected by exactly one thing: its file permissions.

That is enough to stop another ordinary user on the same machine. It is not enough for anything else. Anyone with root can read it. An unencrypted backup of your home directory contains it in the clear. A laptop that is stolen while running, or with an unencrypted disk, gives it up. And the file is the whole game: it holds every repo’s passphrase, so it opens every archive on every drive at once.

Encrypting it moves the secret from “protected by a permission bit” to “protected by a key that lives somewhere else.”

It does not protect you from a machine that is compromised while you are using it. When you run a backup, the file is decrypted, in memory, on that machine. Nothing in this part changes that, and no arrangement of files can.

What GPG is, in one paragraph

GPG gives you a key pair. The public key encrypts, the private key decrypts, and only the private key can undo what the public key did. The private key is itself protected by a passphrase, which you type. A background program, the GPG agent, remembers that passphrase for a while so you are not retyping it every few seconds.

So after this part you have two layers. The borg passphrases are encrypted to your GPG key, and your GPG key is protected by its own passphrase. That second passphrase is the one thing you must never lose and must never write into the backups.

What changes in the tool: nothing

This is the good news, and it is why this part is short.

The tool does not decide what kind of passphrase file it has from the filename. It reads the file. A file whose lines start with set_pass is plain text. A file that begins with -----BEGIN PGP MESSAGE-----, or that contains bytes that are not printable text, is treated as GPG-encrypted and decrypted before use.

So you encrypt the file where it already sits. PASSPHRASE_PATH does not change. ~/.borg-config does not change. No command changes. The next backup run notices the file is now ciphertext and asks GPG to open it.

You may rename it to something like ~/.borg-pass.gpg if you find that clearer, and if you do, update PASSPHRASE_PATH to match. The tool does not care either way.

Doing it

Step 1: install gpg and make a key

sudo apt install gnupg

The backup script installs borg for you but not gpg, so this one is yours to run.

If you do not already have a key:

gpg --full-generate-key

Take the defaults, use a real email address as the identity, and give it a long passphrase. Then find its identifier:

gpg --list-secret-keys --keyid-format=long

Step 2: get the private key out of the machine, first

Do this before you encrypt anything. The order matters, because after step 3 the key is the only route to your backups.

gpg --export-secret-keys --armor you@example.com > secret-key.asc
gpg --export --armor you@example.com > public-key.asc
gpg --gen-revoke you@example.com > revoke.asc

Export the public key as well as the secret one. It is not sensitive, and you need it to rebuild from paper. gpg --gen-revoke asks you for a reason, a confirmation, and the key’s passphrase before it writes anything; keep revoke.asc with the exports, it is what you publish if the key is ever stolen.

Put them on a USB stick that is not one of your backup drives, and keep it somewhere physically separate. Consider a paper copy as well:

sudo apt install paperkey
gpg --export-secret-keys you@example.com | paperkey --output secret-key-paper.txt

paperkey strips out everything reconstructible from the public key, leaving a much smaller amount to print. The output is a hex dump with line numbers and a checksum on each line, so it can be typed back in and will tell you which line you got wrong. A printed key in a drawer survives things a USB stick does not.

Rebuilding later needs the printout and the public key together. paperkey reads only the binary form of the public key, so the first line converts it back; given public-key.asc directly, it prints unable to parse OpenPGP packets and writes an empty file:

gpg --dearmor --output public-key.gpg public-key.asc
paperkey --pubring public-key.gpg --secrets secret-key-paper.txt --output secret-key.gpg
gpg --import secret-key.gpg

Two things a paper copy does not do. It does not remove the passphrase, so if the secret key was protected by one the rebuilt key is too; paper rescues you from a dead disk, not from a forgotten passphrase. And it is not self-contained, so store public-key.asc with the printout or there is nothing to rebuild against.

Then delete secret-key.asc from your disk once it is safely elsewhere: shred -u secret-key.asc.

Step 3: encrypt the passphrase file in place

gpg --encrypt --recipient you@example.com --output ~/.borg-pass.new ~/.borg-pass
chmod 600 ~/.borg-pass.new

Check that it actually decrypts before you throw the original away:

gpg --decrypt ~/.borg-pass.new

You should see your set_pass lines. If you do, swap the files in:

mv ~/.borg-pass.new ~/.borg-pass

Confirm the whole thing still works end to end:

backup check

If that verifies your repos, the tool decrypted the file and used the passphrases.

Step 4: get rid of the plain text copy

The original is gone from that path, but it may still exist elsewhere. Check your editor’s backup files, your Downloads folder, and any place you copied it while following Part 1.

More importantly, the plaintext version is already inside your existing archives. Every backup you took during Part 1 contains it. That is not fixable by deleting a file; those archives are what they are. If it matters to you, take the view that the old archives are as sensitive as the old file was, and let retention age them out.

What it costs you

Cron becomes unreliable. An unattended run needs the GPG agent already primed with your key passphrase, and an agent’s cache expires. A backup that fires at 20:30 on a machine you have not touched since morning will find no primed agent and fail, or hang waiting for a prompt that nobody will answer. This is why the cron section lives in Part 1 and not here. If you want both, keep the plain text file and rely on full-disk encryption instead; that is a legitimate choice, not a lesser one.

Every borg call reopens the file. This surprises people, so it is worth being exact. The tool never holds your passphrases in its own long-running process. It hands borg a command to fetch a passphrase, and borg runs that command fresh for each borg operation that needs the key. One repo on one drive involves an archive, then a prune if you set retention, then a verification pass, and each of those is a separate borg process that fetches the passphrase again. Add one more at the start of every run, when the tool works out which repos have a stored passphrase at all.

With the agent holding your key passphrase, you will not notice; each decryption is silent. With a hardware token that requires a physical touch per operation, you will notice a great deal, because it is one touch per decryption rather than one per run.

One more thing to install on a new machine. Recovery now needs gpg present and your private key imported before the passphrase file is of any use.

The danger, stated plainly

If you lose the GPG private key, or forget its passphrase, the passphrase file cannot be opened. If the passphrase file cannot be opened, the borg repos cannot be opened. If the borg repos cannot be opened, every backup you have is a directory of random-looking data, permanently.

There is no recovery, no reset, and nobody to appeal to. This is not a flaw in the design; it is what encryption is.

Three rules follow from that, and none of them is optional.

The private key must exist outside the backups. If your only copy of the key is inside an encrypted repo, you have built a lock whose key is inside the box. The repo needs a passphrase, the passphrase is encrypted to the key, the key is in the repo. Keep the export and the paper copy on separate media, in a separate place. Note that neither covers a forgotten passphrase: both rebuild a key that is still protected by it.

At least one repo’s passphrase must be recoverable without any of this. Written down, in a safe, in a password manager on your phone, on paper in an envelope, whatever suits you. On a dead machine you restore that one repo by pointing borg at it directly and typing the passphrase, which gets your config and passphrase file back, and everything else follows. Without that, a lost GPG key is total.

Test the recovery, once, on purpose. Import your key onto a different machine or a fresh user account, copy a drive’s repo over, and restore something. An untested recovery is a belief, not a backup.

Rotating a passphrase after this

backup pass-change <repo> still works exactly as before.

It changes the repo’s passphrase on every drive, then rewrites the passphrase file. When that file is encrypted it re-encrypts to the same recipients the file already had, checks that the result decrypts before replacing anything, and leaves the original in place if that check fails. So a failed re-encryption cannot destroy a good file.

If the tool cannot work out who the file was encrypted to, it stops and tells you to re-encrypt by hand rather than guess.

Doing this without the script

The script’s only role here is reading the encrypted file for you. Everything in this part is gpg, and you can keep the same arrangement without it.

Keep the passphrases wherever you like and hand one to borg by hand. The simplest version has no file at all: let borg prompt.

export BORG_REPO=/media/john/d1/documents
borg create "::{now}" /home/john/./Documents

Borg asks, you type, nothing is stored. This is the most secure arrangement there is, and the least convenient.

Or keep one encrypted file and feed it to borg without the script. Borg takes a command to run whenever it needs a passphrase:

export BORG_REPO=/media/john/d1/documents
export BORG_PASSCOMMAND="gpg --quiet --decrypt /home/john/.borg-documents.gpg"
borg create "::{now}" /home/john/./Documents

Where that file holds nothing but the one repo’s passphrase, encrypted. Create it with gpg reading from the keyboard, so the passphrase never touches your shell history: run the command below, type the passphrase, press Enter, then press Ctrl-D.

gpg --encrypt --recipient you@example.com --output ~/.borg-documents.gpg
chmod 600 ~/.borg-documents.gpg

One file per repo, rather than one file holding them all. That is more files but a smaller blast radius, and it removes the parsing step entirely. Note the two rules the script enforces and you now have to keep yourself: the file must be readable by you alone, and the path in BORG_PASSCOMMAND must have no spaces, quotes or backslashes, because borg splits that string without a shell.

Never do this instead.

export BORG_PASSPHRASE='the passphrase'

It reaches borg, and it also reaches your shell history, anything that dumps your environment, and every process running as you through /proc. BORG_PASSCOMMAND exists so you do not have to.

Rotate a passphrase by hand.

BORG_REPO=/media/john/d1/documents borg key change-passphrase
BORG_REPO=/media/john/d2/documents borg key change-passphrase

One call per drive, because every copy has its own key, and then re-encrypt whatever file holds it. A rotation that reaches three drives out of four leaves the fourth on the old passphrase with nothing to tell you.

Re-encrypt the combined file after an edit.

umask 077
gpg --decrypt ~/.borg-pass > /dev/shm/pass.tmp
# edit /dev/shm/pass.tmp
gpg --encrypt --recipient you@example.com --output ~/.borg-pass.new /dev/shm/pass.tmp
gpg --decrypt ~/.borg-pass.new                 # confirm it opens before you swap
mv ~/.borg-pass.new ~/.borg-pass
shred -u /dev/shm/pass.tmp

The umask 077 line makes every file this terminal creates from then on readable by you alone; without it, the decrypted copy in /dev/shm can be read by every account on the machine while you edit it. Decrypt to /dev/shm, which is memory rather than disk, and check the new file decrypts before replacing the old one. Those two habits are what the script’s rotation does for you, and they are the two that people skip.

When something looks wrong

could not read passphrases from <file> (for a .gpg: can gpg decrypt it now ...) The agent is not primed, the key is not present, or the file is not what you think it is. Test it directly with gpg --decrypt <file>.

refusing to archive: <file> is present but unreadable, so every repo would be silently skipped The right behaviour, and worth understanding. Rather than treat an undecryptable file as “no passphrases stored” and quietly back up nothing, the run stops.

cannot read the GPG recipients of <file> to re-encrypt it A rotation got as far as the drives but could not rewrite the passphrase file. The drives are on the new passphrase and the file is not, so fix the file by hand before the next run, using the message the tool printed.

Prompts that never appear under cron. See the cost section above. This is expected, not a bug.

Where to go next

Manual Borg and GPG drops the script entirely and shows you every command underneath, borg and GPG alike, in more depth than the section above.

Borg-simple core is a much shorter script that reads the same config and the same passphrase file, GPG or plain, and decides which from the file’s own bytes rather than its name. It backs up every repo and extracts one repo from one drive, and nothing else. Keep it for the day the main script will not run.

Borg-simple script

This page carries the whole of borg-simple, the script the Levelling up guides set up and configure. Hover over the code block and press the copy button to take all of it, then save it as backup.sh the way Part 1, step 1 shows.

To devs and reviewers: This script is about 3,100 lines long and the result of vibe-coding with Opus 4.7, 4.8, 5, a few times with Fable 5, and about 200 iterations (for real). Anthropic helped but also exasperates; I can’t wait to upgrade my rig and install Qwen or something. Anyway I’ve tested all the user-facing functions, and they work. I’m not a coder, so please review this script if you can.

If this is too long, then please consider reviewing the core part of the script, which contains only the core functions for easy review. Hopefully, there’s nothing malicious. I’ve been using the simple script for my daily backup-workflow.


#!/bin/bash
# borg-simple, v192
#
# Encrypted, versioned borg backups to USB drives. You keep one config file,
# ~/.borg-config (what to back up, to which drives, how long to keep it); the
# script reads it on every run, and init and rename are the only commands that
# write to it. Passphrases live in one file you point PASSPHRASE_PATH at, named
# whatever you like (chmod 600), gpg-encrypted or plaintext: the script reads
# which from the file's own bytes. With no path set, borg prompts per repo.
# Run it with no arguments to back up every repo, with a repo name to back up one,
# and with --help for the command list. The name you type is whatever this file was
# installed as, and the help and error messages show that name back to you.
# When a drive's files belong to another user, 'claim' takes ownership of that
# drive's mountpoint and its repo directories after showing what it will take
# and asking; it is the only place this tool uses sudo on a backup path.
# If borg itself is missing, the first command that needs it installs
# borgbackup with apt, so a fresh machine needs only this script. borg 1.4 or
# newer and below 2.0 is required, and anything outside that is refused.
# Setup, the config format, and cron use are in the guide.

set -euo pipefail

## ─── config location ─────────────────────────────────────────────────
# Every path this tool resolves hangs off $HOME, and under set -u an unset HOME
# aborts on the next line with a raw bash error, before help or version can run.
# die() is not defined yet here, so this says it the long way.
if [[ -z "${HOME:-}" ]]; then
    printf '%s\n' "HOME is not set, so the config path cannot be resolved; run from a login shell, or set HOME" >&2
    exit 1
fi
readonly CONFIG_FILE="$HOME/.bc"

# The single source of truth for the CLI surface. The dispatch case lists these,
# _reject_reserved_name forbids them as repo names by reading this list, and
# _selfcheck asserts each has a handler, a dispatch branch and a help-banner line.
readonly SUBCOMMANDS=(init extract list check claim pass-change rename version)

## ─── output ──────────────────────────────────────────────────────────
say()  { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
die()  { printf '%s\n' "$*" >&2; exit 1; }
die_usage() { printf '%s\n' "$*" >&2; exit 2; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 not found; install it first"; }

## ─── baked defaults: the settings you edit ───────────────────────────
# Built-in settings, so the script runs with no config file. Your ~/.borg-config
# overrides any line it sets; lines it omits keep the value here.
MOUNT_BASE="/media/$(id -un)"   # drives mount under here, so drive d1 is MOUNT_BASE/d1; override for non-udisks layouts
RESTORE_PATH="$HOME/Downloads"  # extract restores a named repo into here; the whole-suite form uses RESTORE_PATH/<repo>
# SRC_HOME remaps an in-place restore of another machine's backup: folders go
# back to the parent named on each backup_data line, so when those sit under a
# foreign home (say /home/john) set this to it and they land under yours instead.
# "" = off. It cannot help a repo whose backup_data is a home directory itself:
# the archive stores that folder under its own name, so nothing is left to remap.
SRC_HOME=""
REPO_SUBDIR=""                  # optional folder between the drive and the repo; "" = repo at the drive root
PASSPHRASE_PATH=""              # one passphrase file, any name, gpg-encrypted or plaintext (detected from its content); "" = borg asks
ALL_DRIVES="d1 d2 d3 d4"        # the drive labels; a repo using `backup_drives all` gets all of these

## ─── internal state ──────────────────────────────────────────────────
# Repo blocks. CURRENT_REPO tracks the open block while sourcing the config.
CURRENT_REPO=""
declare -a REPOS=()           # repo names, in config order
declare -A REPO_SEEN=()       # name -> 1
declare -A REPO_SRC=()        # name -> newline-joined absolute source folders
declare -A REPO_DRIVES=()     # name -> space-joined drive labels
declare -A REPO_EXCLUDES=()   # name -> newline-joined exclude globs (emitted as '- sh:' patterns)
declare -A REPO_INCLUDES=()   # name -> newline-joined include_only globs
declare -A REPO_ARCHIVE=()    # name -> 1 archived (default), 0 if `archive no`
declare -A REPO_COMPRESSION=() # name -> borg compression spec; empty = borg default (lz4)
declare -A REPO_FINCLUDE_GLOBS=() # "name + newline + abs-folder" -> newline-joined folder-scoped include_only globs
declare -A REPO_HAS_FINCLUDE=()   # name -> 1 if the repo has any folder-scoped include_only line
declare -A REPO_RESTORE_TO=()     # "name + newline + abs-folder" -> absolute parent the folder restores into
declare -a PASS_SKIPPED=()    # repos skipped this run for a missing passphrase
declare -A PASS=()            # name -> passphrase; set only in the _emit-pass/_pass-names children and the writers, never on a routine run
declare -A PASS_NAMES=()      # name -> 1 for repos with a set_pass entry; learned via the names-only child, values never enter the parent
_PASS_NAMES_LOADED=0
_PASS_NAMES_UNREADABLE=0      # PASSPHRASE_PATH is present but the names child failed: no exec, no decrypt, or no set_pass lines

# Holds '--bypass-lock' for the current read when the repo directory is not
# writable, so borg can read a repo whose files another user owns, or one on
# read-only media, without creating a lock file. Empty for a writable repo, so a
# genuine stale lock on your own repo still fails loudly and tells you to
# break-lock. Set per read by _set_read_lock; never on a write path.
declare -a RO_LOCKOPT=()
declare -A _RO_WARNED=()

# Deduplicated bytes added across one archive run, counted once per repo (its
# first archived drive) so mirror drives don't multiply it.
RUN_DEDUP_BYTES=0
declare -A RUN_REPO_DEDUP_COUNTED=()

# Repos whose allowlist has already been checked against borg's matcher this
# run. The answer is a property of the config and the source tree, not of the
# drive being written, so one check per repo covers all its copies.
declare -A RUN_REPO_FILTER_CHECKED=()

# Every drive-level reason a repo came out short, in order. Each was already
# warned, but warn goes to stderr while the summary goes to stdout, so a cron job
# keeping only stdout got the verdict and none of the causes. Replayed on stdout
# just above the summary, so the run is answerable from one stream.
declare -a RUN_ISSUES=()

# What the repo loop last printed, so a blank line can separate a run's per-repo
# blocks: "block", "skip", or "" before anything. Consecutive skips stay grouped,
# since a blank line around every one-liner is noisier than the run it separates.
_RUN_LAST=""

# Retention. A repo is pruned only if it has a keep line; these defaults fill in
# any key such a line leaves unset.
readonly KEEP_LAST_DEFAULT=0
readonly KEEP_HOURLY_DEFAULT=0
readonly KEEP_DAILY_DEFAULT=7
readonly KEEP_WEEKLY_DEFAULT=4
readonly KEEP_MONTHLY_DEFAULT=-1
readonly KEEP_YEARLY_DEFAULT=-1
declare -A SEEN_KEEP=()
declare -A KEEP_LAST=()
declare -A KEEP_HOURLY=()
declare -A KEEP_DAILY=()
declare -A KEEP_WEEKLY=()
declare -A KEEP_MONTHLY=()
declare -A KEEP_YEARLY=()

## ─── helpers ─────────────────────────────────────────────────────────
_note_issue() { RUN_ISSUES+=("$1"); }

# Comma-join the arguments into "a, b, c"; nothing for an empty list.
_csv() {
    (( $# > 0 )) || return 0
    local acc="$1" x; shift
    for x in "$@"; do acc+=", $x"; done
    printf '%s' "$acc"
}

# One wording for an absent drive, everywhere. The path is load-bearing: a drive
# that is plugged in and mounted still reports "not mounted" whenever MOUNT_BASE
# points somewhere else, and only the path tells that apart from an absent drive.
_not_mounted() { printf '%s is not mounted at %s' "$1" "$MOUNT_BASE/$1"; }

# borg is the one dependency a fresh machine will not have, and a fresh machine
# is exactly where this tool has to work, so install it rather than refuse.
ensure_borg() {
    if command -v borg >/dev/null 2>&1; then
        _require_borg_version
        return 0
    fi
    say "borg not found; installing borgbackup..."
    sudo apt-get update                || die "apt-get update failed"
    sudo apt-get install -y borgbackup || die "could not install borgbackup"
    command -v borg >/dev/null 2>&1 || die "borgbackup installed but borg still not on PATH"
    _require_borg_version
}

# Every source folder is handed to borg as <parent>/./<folder> so it lands at the
# archive root under its own basename. That prefix strip is borg's slashdot hack,
# added in the 1.4 series; 1.2.x ignores the /./ silently and stores every item
# under its full path instead. Nothing fails at backup time, so the damage shows
# up only at restore, which is the worst failure direction this tool has.
# The upper bound matters as much: this tool parses deduplicated_size and
# unique_csize out of `borg create --json` (both gone in 2.0), calls a bare
# `borg prune` (2.0 wants an archive glob) and uses the ::archive syntax 2.0
# changed, and Debian trixie ships a borgbackup-is-borgbackup2 package that
# repoints the `borg` command at 2.x. Refuse both ends, and say which was hit.
_require_borg_version() {
    local out ver major minor
    out=$(borg --version 2>/dev/null | head -n1) || die "could not run 'borg --version'"
    [[ -n "$out" ]] || die "'borg --version' printed nothing; cannot confirm borg is 1.4 or newer"
    ver="${out##* }"
    major="${ver%%.*}"
    minor="${ver#*.}"; minor="${minor%%.*}"
    case "$major$minor" in
        ''|*[!0-9]*) die "could not read a version number from 'borg --version' (got: '$out'); this tool needs borg 1.4 or newer, below 2.0" ;;
    esac
    (( major == 1 && minor >= 4 )) && return 0
    (( major >= 2 )) \
        && die "borg $ver is too new; this tool is written against borg 1.x and would fail partway through on 2.x (changed create --json fields, prune arguments, and repo::archive syntax). Install the borgbackup 1.4 package, or check whether borgbackup-is-borgbackup2 has repointed the borg command"
    die "borg $ver is too old; this tool needs borg 1.4 or newer, which is where borg learned to strip the /./ prefix this tool's archive layout depends on. Upgrade borgbackup, or read these repos with borg directly"
}

# Absolute, symlink-resolved path to this script, for the BORG_PASSCOMMAND that
# re-invokes it. A bare $0 (PATH-resolved, e.g. cron) goes through command -v.
_resolve_self() {
    local src="$0" pth
    case "$src" in
        */*) pth="$src" ;;
        *)   pth=$(command -v -- "$src" 2>/dev/null) || pth="$src" ;;
    esac
    readlink -f -- "$pth" 2>/dev/null || printf '%s\n' "$pth"
}
SELF=$(_resolve_self)
readonly SELF

# The name this was invoked as, used by every message that tells you what to
# type. install_scripts deliberately lets a file be stored under one name and
# installed as another, so a message hardcoding either one is wrong for somebody.
CMD="${0##*/}"
readonly CMD

# One backup operation at a time; the fd-9 lock auto-releases on process exit.
# The lock lives beside the config in $HOME, not under $XDG_RUNTIME_DIR or /tmp:
# a $HOME path is per-user by construction, so no other local user can pre-create
# the name as a symlink and have this exec 9> truncate a file you own.
_acquire_lock() {
    local lock="${CONFIG_FILE}.lock"
    exec 9>"$lock"
    flock -n 9 || die "another backup operation is in progress"
}

# The mount-level reasons a write or a chown cannot work, said once for both
# callers so the two wordings can never drift apart. $1 is the mountpoint, $2 its
# fstype, $3 its options, $4 the indent every line carries, $5 the consequence
# clause the caller wants on the read-only line. Returns 1 for a read-only mount,
# 2 for a filesystem that carries no ownership, 0 when neither applies.
_fs_blocker() {
    local mnt="$1" fstype="$2" opts="$3" pad="${4:-}" ro_because="${5:-}"
    case ",$opts," in
        *,ro,*)
            warn "$pad$mnt is mounted read-only, $ro_because"
            warn "$pad  remount it:  sudo mount -o remount,rw $mnt"
            return 1 ;;
    esac
    case "$fstype" in
        vfat|exfat|ntfs|ntfs3|fuseblk)
            warn "$pad$mnt is $fstype, which stores no ownership; the mount's uid= option decides it, so chown cannot help"
            warn "$pad  remount as you:  sudo mount -o remount,uid=\"\$(id -u)\",gid=\"\$(id -g)\" $mnt"
            return 2 ;;
    esac
    return 0
}

# The remedy for an unwritable directory, named for the cause rather than offered
# as a menu. Three things produce the same symptom and only one is fixed by
# taking ownership: a read-only mount refuses the chown too, and vfat/exfat/ntfs
# carry no on-disk ownership at all (the mount's uid= option decides it). Ask the
# mount which case this is and print only that remedy. $1 is the directory, $2
# the drive label when the caller knows it. Advice only; nothing is run here.
_unwritable_hint() {
    local dir="${1:-}" label="${2:-}" fstype="" opts="" mnt=""
    if [[ -n "$dir" ]] && command -v findmnt >/dev/null 2>&1; then
        read -r fstype opts mnt < <(findmnt -no FSTYPE,OPTIONS,TARGET -T "$dir" 2>/dev/null) || true
    fi
    # A chown or remount aimed at / would be aimed at the whole machine, which
    # means the drive is not mounted where the caller thought. Say that instead
    # of printing a command that must not be run.
    if [[ "$mnt" == "/" ]]; then
        warn "  $dir is on your root filesystem, so that drive is not mounted; mount it and re-run"
        return 0
    fi
    if [[ -z "$mnt" ]]; then
        local show="${dir:-<the repo directory>}"
        warn "  inspect:  findmnt -no FSTYPE,OPTIONS -T $show   and   ls -ld $show"
        return 0
    fi
    local blocker=0
    _fs_blocker "$mnt" "$fstype" "$opts" "  " "so nothing can be written to it" || blocker=$?
    case "$blocker" in
        1) warn "  if that fails, the drive may have a physical write-lock switch, or a damaged filesystem"
           return 0 ;;
        2) return 0 ;;
    esac
    warn "  the files under $mnt belong to another user, and borg needs the repo directory itself writable"
    if [[ -n "$label" ]]; then
        warn "  take it:  $CMD claim $label"
    else
        warn "  take it:  $CMD claim <drive>   (or: sudo chown -R \"\$(id -un):\$(id -gn)\" $mnt)"
    fi
    return 0
}

# The first path under a borg repo that cannot be written, printed on stdout, or
# nothing when every one of them can. The top directory is only where borg puts
# its lock file: the archive goes into data/, and config and nonce are rewritten
# on every run, so a repo whose directory is yours and whose contents are not
# passes a top-directory test and then fails several steps into borg with a
# Python traceback. That is what a partly-claimed drive leaves behind. Only paths
# that already exist are tested, so a directory that is not a borg repo at all is
# not accused of a permission problem. Returns 0 either way; read the output.
_first_unwritable() {
    local repo="$1" p
    [[ -w "$repo" ]] || { printf '%s\n' "$repo"; return 0; }
    for p in data config nonce; do
        if [[ -e "$repo/$p" && ! -w "$repo/$p" ]]; then
            printf '%s\n' "$repo/$p"
            return 0
        fi
    done
    return 0
}

# The read mirror of _first_unwritable, same contract and same reason. One extra
# point: --bypass-lock cannot rescue an unreadable repo, since the lock is not
# the obstacle there, which is why _set_read_lock distinguishes the two.
_first_unreadable() {
    local repo="$1" p
    [[ -r "$repo" && -x "$repo" ]] || { printf '%s\n' "$repo"; return 0; }
    for p in config data; do
        [[ -e "$repo/$p" ]] || continue
        if [[ ! -r "$repo/$p" ]] || { [[ -d "$repo/$p" ]] && [[ ! -x "$repo/$p" ]]; }; then
            printf '%s\n' "$repo/$p"
            return 0
        fi
    done
    return 0
}

# Decide the lock mode for a read of repo dir $1: bypass borg's lock when the
# directory is not writable, warning once per directory so a scan across several
# drives does not repeat itself. Read-only callers only (list, extract, check);
# a write path must hold the lock, never this.
_set_read_lock() {
    local repo="$1" label="${2:-}"
    if [[ -w "$repo" ]]; then
        RO_LOCKOPT=()
        return 0
    fi
    RO_LOCKOPT=(--bypass-lock)
    [[ -z "${_RO_WARNED[$repo]:-}" ]] || return 0
    _RO_WARNED["$repo"]=1
    # Say which of the two it is. Bypassing the lock reads an unwritable repo
    # fine; it does nothing for one that cannot be read, and claiming otherwise
    # promises a workaround that borg then fails on a few steps later.
    local blocked; blocked=$(_first_unreadable "$repo")
    if [[ -n "$blocked" ]]; then
        warn "$blocked cannot be read, and no lock option changes that"
    else
        warn "$repo is not writable; reading with borg's lock bypassed (assumes nothing else is writing it)"
    fi
    _unwritable_hint "$repo" "$label"
}

# Offer to take ownership of a drive when there is a terminal, and print the
# remedy when there is not. Returns 0 only when a claim actually ran and
# succeeded, so the caller can re-test rather than assume.
_offer_claim() {
    local drive="$1" dir="$2"
    if [[ -t 0 ]] && _claim_drive "$drive"; then return 0; fi
    _unwritable_hint "$dir" "$drive"
    return 1
}

# Refuse an all-or-nothing operation unless every listed directory is writable,
# offering to claim the drives that are not. $1 names the operation; the rest are
# "<drive>TAB<dir>" pairs. Dies rather than proceeding on a subset: a rename or a
# rotation that lands on some drives and not others leaves the config and the
# repos out of step, which then has to be repaired by hand.
_require_writable() {
    local op="$1"; shift
    local pair drive dir
    local -a blocked=() still=()
    for pair in "$@"; do
        dir="${pair#*$'\t'}"
        [[ -z "$(_first_unwritable "$dir")" ]] || blocked+=("$pair")
    done
    (( ${#blocked[@]} > 0 )) || return 0
    warn "$op needs these to be writable, and they are not:"
    for pair in "${blocked[@]}"; do warn "  ${pair#*$'\t'}"; done
    for pair in "${blocked[@]}"; do
        drive="${pair%%$'\t'*}"; dir="${pair#*$'\t'}"
        _offer_claim "$drive" "$dir" || true
        [[ -z "$(_first_unwritable "$dir")" ]] || still+=("$drive")
    done
    (( ${#still[@]} == 0 )) \
        || die "$op refused: still not writable on ${still[*]}; nothing was changed"
    return 0
}

## ─── config: validators and path resolution ──────────────────────────
_validate_segment() {
    local kind="$1" value="$2"
    case "$value" in
        ''|.|..)           die "config: invalid $kind '$value'" ;;
        -*)                die "config: $kind may not start with '-': '$value'" ;;
        *[!A-Za-z0-9._-]*) die "config: $kind has an unsafe character (allowed: letters, digits, . _ -): '$value'" ;;
    esac
    return 0
}

# Repo names share the command line with subcommands, so a repo named like one
# would be unreachable: `<cmd> <name>` would dispatch to the subcommand instead
# of archiving the repo. Reserve the visible dispatch words, the help flags, and
# the hidden dispatch words. Drive labels are never dispatched, so this guards
# repo names only, at the three places a repo_name line is born: the config
# directive, `init` creating a repo, and `rename`.
_reject_reserved_name() {
    local name="$1" w
    for w in "${SUBCOMMANDS[@]}" help -h --help _emit-pass _pass-names _selfcheck; do
        [[ "$name" == "$w" ]] \
            && die "repo name '$name' is reserved for the '$name' command; pick another name"
    done
    return 0
}

_require_absolute_path() {
    local kind="$1" path="$2"
    [[ "$path" == /* ]] || die "config: $kind path must be absolute (start with /): '$path'"
}

# One validator for a backup_drives value ('all' or a list of labels), used by
# the config directive and by init's --drives, so a bad drive is rejected the
# same way wherever it is written. $1 prefixes every message with its context.
_validate_drives_list() {
    local what="$1" drives="$2" d seen=""
    if [[ "$drives" == all ]]; then
        [[ "$ALL_DRIVES" =~ [^[:space:]] ]] \
            || die "$what: 'all' needs ALL_DRIVES set in $CONFIG_FILE, but it is empty"
        what="$what: ALL_DRIVES"
        drives="$ALL_DRIVES"
    fi
    # shellcheck disable=SC2086  # space-separated label list; intentional split
    for d in $drives; do
        [[ "$d" != all ]] || die "$what: 'all' is reserved for the ALL_DRIVES pool; a drive may not be named all"
        _validate_segment "drive" "$d"
        case " $seen " in *" $d "*) die "$what lists drive '$d' twice" ;; esac
        seen+="$d "
    done
    return 0
}

# Resolve a config path: a leading ~/ or a bare relative path is taken under
# $HOME, an absolute path passes through, and empty stays empty.
_resolve_home_path() {
    local p="$1" out
    [[ -n "$p" ]] || return 0
    # A shellcheck directive is only honoured in front of a complete command, so
    # it sits above the whole case rather than above the one branch it is for:
    # the '~/' pattern below is a literal, expanded by hand on the right-hand
    # side, not a failed tilde expansion. Do not move it onto the branch. There
    # it is a parse error that stops shellcheck and leaves the rest unchecked.
    # shellcheck disable=SC2088
    case "$p" in
        /*)    out="$p" ;;
        '~')   out="$HOME" ;;
        '~/'*) out="$HOME/${p#'~/'}" ;;
        *)     out="$HOME/$p" ;;
    esac
    # Drop a trailing slash so a folder written with or without one resolves
    # identically; never reduce the root "/" to empty.
    [[ "$out" == / ]] || out="${out%/}"
    printf '%s\n' "$out"
}

## ─── config: directives ──────────────────────────────────────────────
# repo_name opens a block; the directives under it attach to this repo until
# the next repo_name. To remove a repo, comment or delete its whole block.
repo_name() {
    (( $# == 1 )) || die "config: repo_name needs exactly one name; got: $*"
    _validate_segment "repo name" "$1"
    _reject_reserved_name "$1"
    [[ -z "${REPO_SEEN[$1]+x}" ]] || die "config: repo $1 declared more than once"
    REPO_SEEN["$1"]=1
    REPOS+=("$1")
    CURRENT_REPO="$1"
}

# A directive with no open repo means a repo_name was commented out but its
# lines were left behind; fail loudly rather than silently misattach them.
_require_open_repo() {
    [[ -n "$CURRENT_REPO" ]] \
        || die "config: $1 with no repo_name above it (did you comment out a repo_name but leave its lines?)"
}

# Folders to back up for the current repo. Each is stored at the archive root
# under its own basename, so two folders in one repo must not share a basename.
backup_data() {
    _require_open_repo "backup_data"
    (( $# >= 1 )) || die "config: backup_data needs at least one folder path"
    local p abs base
    for p in "$@"; do
        abs=$(_resolve_home_path "$p")
        _require_absolute_path "backup_data folder" "$abs"
        base=$(basename "$abs")
        _validate_segment "backup_data folder name (basename)" "$base"
        case $'\n'"${REPO_SRC[$CURRENT_REPO]:-}" in
            *$'\n'*"/$base"$'\n'*)
                die "config: repo $CURRENT_REPO has two folders named '$base'; they would collide at the archive root" ;;
        esac
        REPO_SRC["$CURRENT_REPO"]+="$abs"$'\n'
    done
}

backup_drives() {
    _require_open_repo "backup_drives"
    (( $# >= 1 )) || die "config: backup_drives needs at least one drive (or 'all')"
    if [[ "$1" == all ]]; then
        (( $# == 1 )) || die "config: backup_drives all takes no other arguments (got: backup_drives $*)"
        _validate_drives_list "config: backup_drives all" all
        REPO_DRIVES["$CURRENT_REPO"]="$ALL_DRIVES"
        return 0
    fi
    _validate_drives_list "config: backup_drives" "$*"
    REPO_DRIVES["$CURRENT_REPO"]="$*"
}

# Retention for the repo. Unset keys inherit: daily=7 weekly=4 monthly=-1
# yearly=-1, rest 0 (-1 keeps a tier forever, 0 none). 'last' nonzero keeps the
# N most recent and ignores the bucket keys.
keep() {
    _require_open_repo "keep"
    (( $# >= 1 )) || die "config: keep needs at least one key=N"
    [[ -z "${SEEN_KEEP[$CURRENT_REPO]+x}" ]] || die "config: keep set more than once for repo $CURRENT_REPO"
    SEEN_KEEP["$CURRENT_REPO"]=1
    local pair key value saw_last=0 saw_bucket=0
    for pair in "$@"; do
        [[ "$pair" == *=* ]] || die "config: keep '$pair' must be key=N"
        key="${pair%%=*}"; value="${pair#*=}"
        [[ "$value" =~ ^(-1|[0-9]+)$ ]] \
            || die "config: keep $key value '$value' must be -1 or a non-negative integer"
        case "$key" in
            last)     KEEP_LAST["$CURRENT_REPO"]="$value";     saw_last=1   ;;
            hourly)   KEEP_HOURLY["$CURRENT_REPO"]="$value";   saw_bucket=1 ;;
            daily)    KEEP_DAILY["$CURRENT_REPO"]="$value";    saw_bucket=1 ;;
            weekly)   KEEP_WEEKLY["$CURRENT_REPO"]="$value";   saw_bucket=1 ;;
            monthly)  KEEP_MONTHLY["$CURRENT_REPO"]="$value";  saw_bucket=1 ;;
            yearly)   KEEP_YEARLY["$CURRENT_REPO"]="$value";   saw_bucket=1 ;;
            *) die "config: keep unknown key '$key' (use last|hourly|daily|weekly|monthly|yearly)" ;;
        esac
    done
    if (( saw_last && saw_bucket && ${KEEP_LAST[$CURRENT_REPO]:-0} != 0 )); then
        warn "config: keep for $CURRENT_REPO: last=${KEEP_LAST[$CURRENT_REPO]} active; bucket keys on this line ignored"
    fi
}

# Denylist for the repo: each pattern becomes a '- sh:' borg pattern (shell-style:
# * stops at /, **/ crosses). Excludes are emitted before any allowlist, so an
# explicit exclude always wins; they compose with both kinds of include_only.
exclude() {
    _require_open_repo "exclude"
    (( $# >= 1 )) || die "config: exclude needs at least one pattern"
    local pat
    for pat in "$@"; do
        [[ -n "$pat" ]] || die "config: exclude empty pattern"
        REPO_EXCLUDES["$CURRENT_REPO"]+="$pat"$'\n'
    done
}

# Repo-wide allowlist: each glob becomes a '+ sh:' include and a global '- sh:**'
# drops the rest. Excludes are emitted before any allowlist, so an explicit
# exclude always wins. Mutually exclusive with include_only_in over one repo
# (checked in _require_repos_valid); both compose with exclude.
#
# The two guards below detect a scoped allowlist written with this directive.
# Neither of them picks a scope, since the directive's name does that; they exist
# because getting the scope wrong silently shortens a backup and is found only at
# restore. So a first token that names one of this repo's folders, or that holds
# no wildcard and therefore cannot be a glob, is refused rather than taken as a
# repo-wide pattern.
include_only() {
    _require_open_repo "include_only"
    (( $# >= 1 )) || die "config: include_only needs at least one glob"
    local first; first=$(_resolve_home_path "$1")
    case $'\n'"${REPO_SRC[$CURRENT_REPO]:-}" in
        *$'\n'"$first"$'\n'*)
            die "config: include_only is repo-wide; to scope an allowlist to one folder write: include_only_in $1 <glob>..." ;;
    esac
    [[ "$1" == *'*'* || "$1" == *'?'* || "$1" == *'['* ]] \
        || die "config: include_only '$1' contains no wildcard, so it is not a glob; for one folder's allowlist write: include_only_in $1 <glob>..."
    local g
    for g in "$@"; do
        [[ -n "$g" ]] || die "config: include_only empty glob"
        REPO_INCLUDES["$CURRENT_REPO"]+="$g"$'\n'
    done
}

# Allowlist scoped to one backup_data folder: its globs are anchored to the
# folder's own name and a '- sh:<base>/**' drops only that folder's rest, leaving
# the repo's other folders whole. One line per folder; mutually exclusive with
# the repo-wide include_only over the same repo.
include_only_in() {
    _require_open_repo "include_only_in"
    (( $# >= 2 )) || die "config: include_only_in needs a backup_data folder and at least one glob"
    local folder="$1"; shift
    local first; first=$(_resolve_home_path "$folder")
    case $'\n'"${REPO_SRC[$CURRENT_REPO]:-}" in
        *$'\n'"$first"$'\n'*) ;;
        *) die "config: include_only_in folder '$folder' is not a backup_data folder of repo $CURRENT_REPO (list it in backup_data above this line)" ;;
    esac
    local key="$CURRENT_REPO"$'\n'"$first"
    [[ -z "${REPO_FINCLUDE_GLOBS[$key]+x}" ]] || die "config: two include_only_in lines for folder '$folder' in repo $CURRENT_REPO"
    REPO_HAS_FINCLUDE["$CURRENT_REPO"]=1
    local g globs=""
    for g in "$@"; do
        [[ -n "$g" ]] || die "config: include_only_in empty glob for '$folder'"
        globs+="$g"$'\n'
    done
    REPO_FINCLUDE_GLOBS["$key"]="$globs"
}

# Where one folder lands on a restore that is not in place. The path names the
# parent the folder arrives in, not the folder itself, so it reads the same way
# -i does: a folder at ~/Documents/g1 given a parent of /mnt/spare arrives at
# /mnt/spare/g1. Folders with no line fall back to the run's restore directory;
# a line present but wrong dies here, at config load, rather than at restore time
# when you are least able to deal with it.
restore_to() {
    _require_open_repo "restore_to"
    (( $# == 2 )) || die "config: restore_to needs a backup_data folder and one absolute parent path"
    local folder="$1" target="$2"
    local first; first=$(_resolve_home_path "$folder")
    case $'\n'"${REPO_SRC[$CURRENT_REPO]:-}" in
        *$'\n'"$first"$'\n'*) ;;
        *) die "config: restore_to folder '$folder' is not a backup_data folder of repo $CURRENT_REPO (list it in backup_data above this line)" ;;
    esac
    local key="$CURRENT_REPO"$'\n'"$first"
    [[ -z "${REPO_RESTORE_TO[$key]+x}" ]] || die "config: two restore_to lines for folder '$folder' in repo $CURRENT_REPO"
    [[ -n "$target" ]] || die "config: restore_to for '$folder' has an empty path"
    local abs; abs=$(_resolve_home_path "$target")
    _require_absolute_path "restore_to path for '$folder'" "$abs"
    # Restoring a folder on top of itself is what -i is for, and doing it from
    # this directive would overwrite live data on a command that promises not to.
    [[ "$abs" != "$(dirname "$first")" ]] \
        || die "config: restore_to for '$folder' names its own parent, which would overwrite the live folder; use '$CMD extract $CURRENT_REPO -i' for that"
    REPO_RESTORE_TO["$key"]="$abs"
}

# archive no excludes the repo from the archive run and drops its passphrase
# need; init, check, extract, claim and pass-change ignore it. Absent means yes.
archive() {
    _require_open_repo "archive"
    (( $# == 1 )) || die "config: archive needs one value: yes or no"
    [[ -z "${REPO_ARCHIVE[$CURRENT_REPO]+x}" ]] || die "config: archive set more than once for repo $CURRENT_REPO"
    case "$1" in
        yes) REPO_ARCHIVE["$CURRENT_REPO"]=1 ;;
        no)  REPO_ARCHIVE["$CURRENT_REPO"]=0 ;;
        *)   die "config: archive must be 'yes' or 'no', got '$1'" ;;
    esac
}

# Passed to borg create untouched; borg validates the spec. Omit for lz4 default.
compression() {
    _require_open_repo "compression"
    (( $# == 1 )) || die "config: compression needs one spec, e.g. auto,zstd,10"
    [[ -z "${REPO_COMPRESSION[$CURRENT_REPO]+x}" ]] || die "config: compression set more than once for repo $CURRENT_REPO"
    [[ -n "$1" ]] || die "config: compression empty spec"
    REPO_COMPRESSION["$CURRENT_REPO"]="$1"
}

# True unless the repo was turned off with `archive no`.
_repo_archives() { [[ "${REPO_ARCHIVE[$1]:-1}" != 0 ]]; }

# Lives in the passphrase file, keyed by repo name.
set_pass() {
    (( $# == 2 )) || die "pass: set_pass needs: <repo> '<passphrase>'; got $# args"
    [[ -z "${PASS[$1]+x}" ]] || die "pass: passphrase for $1 set more than once in $PASSPHRASE_PATH"
    PASS["$1"]="$2"
}

# A file holding secrets, or sourced as shell, must be owner-only. A loose mode
# is tightened here rather than refused: chmod only ever narrows, so there is
# nothing destructive to gate. Two cases are refused instead, both for security:
# chmod follows symlinks, so a symlink here would tighten whatever it points at,
# and a file owned by someone else is not one to quietly adjust or trust. The
# chmod does not undo an exposure that already happened, so when the old mode
# granted read to group or other, say so: rotate what was in it.
require_private() {
    local f="$1" mode
    [[ -e "$f" ]] || die "$f not found; create it (chmod 600) before running"
    [[ -r "$f" ]] || die "$f exists but is not readable"
    mode=$(stat -c '%a' "$f") || die "cannot stat $f"
    if (( (8#$mode & 8#77) == 0 )); then return 0; fi
    [[ ! -L "$f" ]] \
        || die "$f is reachable by group or other (mode $mode) and is a symlink; this will not chmod through it, so fix the file it points at"
    [[ "$(stat -c '%u' "$f")" == "$(id -u)" ]] \
        || die "$f is reachable by group or other (mode $mode) and is not owned by you; its owner must run: chmod 600 $f"
    chmod 600 "$f" || die "$f is reachable by group or other (mode $mode) and chmod 600 failed; fix it by hand"
    warn "$f was mode $mode, reachable by group or other; tightened it to 600"
    if (( (8#$mode & 8#44) != 0 )); then
        warn "it was readable by them until now, so treat anything in it as already read; for the passphrase file that means rotating: $CMD pass-change <repo>"
    fi
    return 0
}

# Source the config with universal validation. Subcommands add their own.
parse_config() {
    local cfg="$CONFIG_FILE"
    # An absent config is fine: the baked defaults stand, and a repo named on the
    # command line can still be adopted from a mounted drive by _adopt_drive_repo.
    # When present, an unrecognised directive (a sibling tool's sync_*
    # line, a stale passphrase_file line) is tolerated rather than fatal, but
    # never silent: a mistyped backup_data would otherwise drop a folder from
    # the backup with no output at all, found only at restore time.
    if [[ -e "$cfg" ]]; then
        require_private "$cfg"
        # shellcheck disable=SC2317  # bash calls this indirectly while sourcing the config; not dead code
        command_not_found_handle() { warn "config: ignoring unknown directive '$1'"; return 0; }
        # shellcheck source=/dev/null
        source "$cfg" || die "failed to load $cfg (check it for a shell syntax error, e.g. an unbalanced quote)"
        unset -f command_not_found_handle
        # set_pass belongs in the passphrase file. A set_pass line here would land
        # in PASS (which nothing reads on a routine run) while the repo is then
        # skipped as "no passphrase set"; catch the misplaced line loudly.
        (( ${#PASS[@]} == 0 )) || die "config: set_pass belongs in the passphrase file, not $CONFIG_FILE; move the line there"
    fi

    MOUNT_BASE=$(_resolve_home_path "$MOUNT_BASE")
    [[ -n "$MOUNT_BASE" ]] || die "MOUNT_BASE is empty; set it in $CONFIG_FILE or keep the baked default"
    [[ -d "$MOUNT_BASE" ]] || die "MOUNT_BASE '$MOUNT_BASE' is not a directory"
    [[ -z "$REPO_SUBDIR" ]] || _validate_segment "REPO_SUBDIR" "$REPO_SUBDIR"
    RESTORE_PATH=$(_resolve_home_path "$RESTORE_PATH")
    # SRC_HOME is a literal foreign-home prefix for in-place remap, deliberately
    # not run through _resolve_home_path (that resolves against the running
    # user's $HOME, which would defeat its purpose).
    if [[ -n "$SRC_HOME" ]]; then
        _require_absolute_path "SRC_HOME" "$SRC_HOME"
        SRC_HOME="${SRC_HOME%/}"
    fi
    PASSPHRASE_PATH=$(_resolve_home_path "$PASSPHRASE_PATH")
    # The file's name carries no meaning: _pass_source_kind reads the format out
    # of the file itself. The one real constraint is that a set path be
    # space-free, because borg splits BORG_PASSCOMMAND itself.
    case "$PASSPHRASE_PATH" in
        '') ;;
        *[[:space:]]*) die "PASSPHRASE_PATH must be space-free for the passcommand (got: '$PASSPHRASE_PATH')" ;;
    esac
    # A path set to a file that is not there reads as 'none' everywhere
    # downstream, which is the same state as PASSPHRASE_PATH="" and produces the
    # same silent outcome. Those are very different intentions, so name the gap.
    if [[ -n "$PASSPHRASE_PATH" && ! -e "$PASSPHRASE_PATH" ]]; then
        warn "PASSPHRASE_PATH names $PASSPHRASE_PATH, but no file is there; every repo will be treated as having no stored passphrase"
    fi
}

# A repo with no backup_data is allowed (it archives nothing yet, reported as
# awaiting data); backup_drives and the filtering rules are required.
_require_repos_valid() {
    (( ${#REPOS[@]} > 0 )) || die "no repos configured; add repo_name blocks to $CONFIG_FILE"
    local r
    for r in "${REPOS[@]}"; do
        [[ -n "${REPO_DRIVES[$r]:-}" ]] || die "config: repo $r has no backup_drives line"
        # Two allowlist scopes over one repo are ambiguous; excludes compose with
        # either, so they are not part of this test.
        if [[ -n "${REPO_INCLUDES[$r]:-}" && -n "${REPO_HAS_FINCLUDE[$r]:-}" ]]; then
            die "config: repo $r has both include_only and include_only_in; use one repo-wide allowlist, or scope every allowlist to a folder"
        fi
    done
}

## ─── repo paths and drive membership ─────────────────────────────────
_repo_declared() { [[ -n "${REPO_SEEN[$1]+x}" ]]; }

# The repo directory on a given drive, and the directory holding it.
_repo_path()   { printf '%s\n' "$MOUNT_BASE/$1${REPO_SUBDIR:+/$REPO_SUBDIR}/$2"; }
_repo_parent() { printf '%s\n' "$MOUNT_BASE/$1${REPO_SUBDIR:+/$REPO_SUBDIR}"; }

# What init must be able to write to create a repo on a drive: the REPO_SUBDIR
# when it is already there, otherwise the mountpoint init would create it under.
_init_target_dir() {
    local p; p=$(_repo_parent "$1")
    if [[ -d "$p" ]]; then printf '%s\n' "$p"; else printf '%s\n' "$MOUNT_BASE/$1"; fi
}

# True when a drive label is one of a repo's configured drives. extract, list,
# check and claim all take a drive, and a typo in it should say so rather than
# fall through to "no mounted drive had it", which points at the wrong problem.
_repo_has_drive() {
    local repo="$1" want="$2" d
    # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
    for d in ${REPO_DRIVES[$repo]}; do
        [[ "$d" == "$want" ]] && return 0
    done
    return 1
}

# A repo's configured drives, comma-joined, for those error messages.
_repo_drives_csv() {
    # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
    _csv ${REPO_DRIVES[$1]}
}

# One wording for a name the config does not declare, and one for a drive the
# named repo does not use, so every command that takes them says the same thing.
_require_declared_repo() {
    _repo_declared "$1" || die "$1 is not a configured repo (check $CONFIG_FILE)"
}
_require_repo_drive() {
    _repo_has_drive "$1" "$2" \
        || die "$2 is not a configured drive for $1 (it has: $(_repo_drives_csv "$1"))"
}

# Last resort for a bare repo name no config declares. MOUNT_BASE, REPO_SUBDIR
# and ALL_DRIVES are all baked, so a repo directory is fully derivable from the
# name alone: look for one on each mounted drive and, when any turn up, declare
# the repo exactly as a config block would. Nothing is written and no config is
# touched; an in-place restore still refuses, correctly, because there are no
# backup_data lines saying where the folders came from. Returns 1 when nothing
# is found, leaving the caller's existing errors in place.
_adopt_drive_repo() {
    local name="$1" d found=()
    # Cheap shape test rather than _validate_segment, which dies: a name that
    # cannot be a path segment should fall through to the caller's message, not
    # abort with a config-parse error for a word that came from the command line.
    case "$name" in
        ''|.|..|-*|*[!A-Za-z0-9._-]*) return 1 ;;
    esac
    [[ "$ALL_DRIVES" =~ [^[:space:]] ]] || return 1
    # shellcheck disable=SC2086  # space-separated label list; intentional split
    for d in $ALL_DRIVES; do
        mountpoint -q "$MOUNT_BASE/$d" || continue
        [[ -d "$(_repo_path "$d" "$name")" ]] || continue
        found+=("$d")
    done
    (( ${#found[@]} > 0 )) || return 1
    repo_name "$name"
    backup_drives "${found[@]}"
    say "'$name' is not in $CONFIG_FILE; using the repo directory found on: $(_csv "${found[@]}")"
    return 0
}

## ─── passphrase file: read, write, load ──────────────────────────────
# Backend read from the file's content, not its name, so the file may be called
# anything. The test is for what actually distinguishes the two: a plaintext file
# carries set_pass lines in the clear and ciphertext does not. Neither the suffix
# nor the first byte decides it, and a UTF-8 BOM is allowed for explicitly or a
# one-repo file whose only set_pass line is line 1 would read as ciphertext. A
# file merely missing its set_pass lines, or empty, stays plaintext so the caller
# reports that rather than a decrypt failure for a file that was never encrypted.
_pass_source_kind() {
    [[ -n "$PASSPHRASE_PATH" && -e "$PASSPHRASE_PATH" ]] || { printf 'none\n'; return; }
    local bom=$'\xef\xbb\xbf'
    if LC_ALL=C grep -qaE "^(${bom})?[[:space:]]*set_pass[[:space:]]" -- "$PASSPHRASE_PATH"; then
        printf 'plain\n'; return
    fi
    if LC_ALL=C grep -qa '^-----BEGIN PGP MESSAGE-----' -- "$PASSPHRASE_PATH" \
       || LC_ALL=C grep -qa '[^[:print:][:space:]]' -- "$PASSPHRASE_PATH"; then
        printf 'gpg\n'
    else
        printf 'plain\n'
    fi
}

# Emit the passphrase file's cleartext on stdout. For .gpg this decrypts
# through a pipe, so cleartext never lands on disk.
_pass_cleartext() {
    case "$(_pass_source_kind)" in
        plain) require_private "$PASSPHRASE_PATH"; cat -- "$PASSPHRASE_PATH" ;;
        gpg)   need gpg; require_private "$PASSPHRASE_PATH"; gpg --quiet --decrypt "$PASSPHRASE_PATH" || die "could not decrypt $PASSPHRASE_PATH" ;;
        none)  die "no passphrase file at $PASSPHRASE_PATH to read" ;;
    esac
}

# Replace the passphrase file atomically (same-dir tempfile). For .gpg, re-encrypt
# to the file's existing recipients and verify the result decrypts before the
# swap, so a bad re-encrypt cannot replace a good file; cleartext is piped, never
# on disk. The EXIT trap covers a die from anywhere below, which would otherwise
# leave the tempfile behind; no caller of this holds an EXIT trap of its own.
_pass_write() {
    local tmp
    tmp=$(mktemp "${PASSPHRASE_PATH}.XXXXXX") || die "could not create tempfile for $PASSPHRASE_PATH"
    trap 'rm -f "$tmp"' EXIT
    trap 'rm -f "$tmp"; exit 130' INT TERM
    case "$(_pass_source_kind)" in
        plain)
            cat > "$tmp" || die "could not write $tmp"
            ;;
        gpg)
            need gpg
            local kids=() kid
            while IFS= read -r kid; do
                [[ -n "$kid" ]] && kids+=( -r "$kid" )
            done < <(gpg --list-packets "$PASSPHRASE_PATH" 2>/dev/null \
                       | sed -n 's/.*keyid \([0-9A-Fa-f]\{8,\}\).*/\1/p')
            (( ${#kids[@]} > 0 )) \
                || die "cannot read the GPG recipients of $PASSPHRASE_PATH to re-encrypt it; re-encrypt the file to your key by hand"
            gpg --quiet --yes --encrypt "${kids[@]}" --output "$tmp" \
                || die "re-encryption of $PASSPHRASE_PATH failed"
            gpg --quiet --decrypt "$tmp" >/dev/null 2>&1 \
                || die "re-encrypted $PASSPHRASE_PATH failed to decrypt; original kept"
            ;;
        none)
            die "no passphrase file at $PASSPHRASE_PATH to update; create it first"
            ;;
    esac
    # chmod after the write: gpg --output can recreate the file under the umask,
    # so set owner-only just before the swap, not before the encrypt.
    chmod 600 "$tmp" || die "could not make $PASSPHRASE_PATH owner-only"
    mv "$tmp" "$PASSPHRASE_PATH" || die "could not write $PASSPHRASE_PATH"
    trap - EXIT INT TERM
}

# The combined passphrase file is never sourced into this long-running process
# on a routine run. borg gets a BORG_PASSCOMMAND that re-invokes the hidden
# _emit-pass child to print one repo's passphrase, and a separate _pass-names
# child reports which repos have an entry, so this process learns the names
# without ever holding a value. The writers below still read the file at write
# time; those are the deliberate cleartext moments.

# Print a BORG_PASSCOMMAND for repo $1, or nothing (borg prompts) when that repo
# has no stored entry. borg runs it with no shell and no guaranteed PATH, so this
# invokes a fixed bash on the script by absolute path, and all three tokens must
# be free of whitespace and quotes because borg splits the string itself.
_repo_passcommand() {
    local repo="$1"
    _repo_has_pass "$repo" || return 0
    case "$BASH$SELF$PASSPHRASE_PATH" in
        *[[:space:]\'\"\\]*) die "passcommand needs paths free of whitespace, quotes, and backslashes (borg splits BORG_PASSCOMMAND with shlex), but one of BASH='$BASH', SELF='$SELF', PASSPHRASE_PATH='$PASSPHRASE_PATH' contains one" ;;
    esac
    printf '%s %s _emit-pass %s %s\n' "$BASH" "$SELF" "$PASSPHRASE_PATH" "$repo"
}

# Hidden value child: decrypt the file, evaluate its set_pass lines, print the
# one repo's passphrase; exit non-zero (borg fails loudly) if absent. The only
# place a passphrase is materialised on a routine run, in a short-lived process
# handing it straight to borg; tracing is refused in main.
_emit_pass() {
    (( $# == 2 )) || { printf '_emit-pass needs <file> <repo>\n' >&2; exit 2; }
    local file="$1" repo="$2" ct
    PASSPHRASE_PATH="$file"
    [[ "$(_pass_source_kind)" != none ]] || { printf 'no passphrase file at %s\n' "$file" >&2; exit 1; }
    ct=$(_pass_cleartext) || exit 1
    eval "$ct"; unset ct
    [[ -n "${PASS[$repo]:-}" ]] || { printf 'no passphrase for %s in %s\n' "$repo" "$file" >&2; exit 1; }
    printf '%s' "${PASS[$repo]}"
}

# Hidden names child: decrypt the file and print the repo names that have a
# set_pass entry, one per line. Names only; values stay in _emit-pass.
_pass_names() {
    (( $# == 1 )) || { printf '_pass-names needs <file>\n' >&2; exit 2; }
    local file="$1" ct k
    PASSPHRASE_PATH="$file"
    [[ "$(_pass_source_kind)" != none ]] || exit 0
    ct=$(_pass_cleartext) || exit 1
    eval "$ct"; unset ct
    for k in "${!PASS[@]}"; do printf '%s\n' "$k"; done
}

# Populate PASS_NAMES once via the names child, so this process learns which
# repos have a passphrase without holding a value. Tolerant: an absent or
# undecryptable file leaves PASS_NAMES empty and sets _PASS_NAMES_UNREADABLE.
# Call this from the parent before anything reaches _repo_passcommand: that runs
# inside a command substitution, and a first load from there would set these
# globals in a subshell that is then discarded, so the file would be re-read once
# per repo per drive and the unreadable flag would never reach the parent.
_load_pass_names() {
    (( _PASS_NAMES_LOADED )) && return 0
    _PASS_NAMES_LOADED=1
    [[ "$(_pass_source_kind)" != none ]] || return 0
    local out rc=0
    out=$("$BASH" "$SELF" _pass-names "$PASSPHRASE_PATH") || rc=$?
    if (( rc != 0 )); then
        _PASS_NAMES_UNREADABLE=1
        warn "could not read passphrases from $PASSPHRASE_PATH (for a .gpg: can gpg decrypt it now, i.e. is gpg-agent primed and the key present? otherwise: does the file contain 'set_pass <repo> ...' lines?)"
        return 0
    fi
    local n
    while IFS= read -r n; do
        [[ -n "$n" ]] && PASS_NAMES["$n"]=1
    done <<< "$out"
    # The child ran and printed nothing: the file parsed but defined no repo.
    # That is what a line written as an assignment produces; only
    # `set_pass <repo> <pass>`, a directive with two arguments, reaches this tool.
    if (( ${#PASS_NAMES[@]} == 0 )); then
        _PASS_NAMES_UNREADABLE=1
        warn "$PASSPHRASE_PATH defines no repo passphrase; every line must read: set_pass <repo> '<passphrase>' (single quotes: the file is read by bash, so a \$ or a backtick in a double-quoted passphrase is expanded and the passphrase is silently altered)"
    fi
    # Explicit, and load-bearing. The loop above ends in a conditional, so an $out
    # with no usable line would otherwise make this function return 1, which
    # aborts every bare caller under set -e with nothing printed. Do not drop it.
    return 0
}

# True when repo $1 has a stored passphrase (names only; no value read).
_repo_has_pass() { _load_pass_names; [[ -n "${PASS_NAMES[$1]:-}" ]]; }

# Require a stored passphrase for each named repo (presence by name only).
_require_pass() {
    local r missing=()
    for r in "$@"; do _repo_has_pass "$r" || missing+=("$r"); done
    (( ${#missing[@]} == 0 )) \
        || die "no passphrase set for ${missing[*]} (add set_pass lines to $PASSPHRASE_PATH)"
}

# One wording for every "borg does the asking" notice. $1 is why nothing is
# stored, $2 what the prompts are for, and $3 is 'twice' when the operation lists
# before it extracts, so the second prompt is expected rather than a retry.
# Always stderr: it is an advisory about how the run will behave, not a result.
_prompt_notice() {
    local why="$1" what="$2" how="${3:-once}"
    local when="once"
    [[ "$how" != twice ]] || when="twice: once to list, once to extract"
    warn "$why; borg will ask for $what, $when"
}

# True when no passphrase file is configured and there is a terminal, i.e. the
# documented "borg asks" fallback is live for this run. Every command honours it
# the same way, by emitting no BORG_PASSCOMMAND. Tty-gated so an unattended run
# fails closed rather than stalling on a prompt nobody can answer. The prompt is
# per borg invocation, not per repo, so a multi-repo pool is a lot of typing;
# that is the price of keeping the passphrase off disk and out of this process.
_pass_prompt_mode() { [[ -z "$PASSPHRASE_PATH" && -t 0 ]]; }

# Archive preflight for the repos passed in: load the names, then mark those that
# are archived and hold folders but have no stored passphrase as skipped (warned).
# In prompt mode nothing is skipped and no names are loaded; borg does the asking.
# The permission check happens in the parent, before the names child reads the
# file, so a mode notice lands on the run's own output rather than in a process
# whose stdout is being captured.
_archive_pass_preflight() {
    if _pass_prompt_mode; then
        _prompt_notice "no passphrase file configured" "each repo on each drive"
        return 0
    fi
    [[ "$(_pass_source_kind)" == none ]] || require_private "$PASSPHRASE_PATH"
    _load_pass_names
    (( _PASS_NAMES_UNREADABLE )) && die "refusing to archive: $PASSPHRASE_PATH is present but unreadable, so every repo would be silently skipped; fix the cause in the warning above and re-run"
    local label missing=()
    for label in "$@"; do
        _repo_archives "$label" || continue            # archive no
        [[ -n "${REPO_SRC[$label]:-}" ]] || continue   # no folders yet (awaiting data)
        _repo_has_pass "$label" || missing+=("$label")
    done
    if (( ${#missing[@]} > 0 )); then
        if [[ -z "$PASSPHRASE_PATH" ]]; then
            warn "no passphrase file configured and no terminal to prompt; skipped this run: ${missing[*]}"
        else
            warn "no passphrase set for ${missing[*]}; skipped this run (add set_pass lines to $PASSPHRASE_PATH)"
        fi
        PASS_SKIPPED=("${missing[@]}")
    fi
}

# Replace the set_pass value for $1 with $2 in the passphrase file. Matching on
# the line rather than splitting it keeps the old value out of any temp file, and
# printf -v keeps the new one out of a command substitution.
_update_pass_value() {
    local label="$1" new_pass="$2" quoted
    printf -v quoted '%q' "$new_pass"
    local ct; ct=$(_pass_cleartext) || die "could not read $PASSPHRASE_PATH; file left untouched"
    local found=0 line out=""
    while IFS= read -r line || [[ -n "$line" ]]; do
        if [[ "$line" =~ ^[[:space:]]*set_pass[[:space:]]+([^[:space:]]+)[[:space:]] ]] \
            && [[ "${BASH_REMATCH[1]}" == "$label" ]]; then
            out+="set_pass ${label} ${quoted}"$'\n'
            found=1
        else
            out+="$line"$'\n'
        fi
    done <<< "$ct"
    (( found )) \
        || die "no set_pass line for $label in $PASSPHRASE_PATH; rotation finished on drives but the file was not updated"
    printf '%s' "$out" | _pass_write
}

# Rewrite the label on the set_pass line, preserving the value and its spacing.
_update_pass_label() {
    local old="$1" new="$2"
    local ct; ct=$(_pass_cleartext) || die "could not read $PASSPHRASE_PATH; file left untouched"
    local found=0 line out=""
    while IFS= read -r line || [[ -n "$line" ]]; do
        if [[ "$line" =~ ^([[:space:]]*set_pass[[:space:]]+)([^[:space:]]+)([[:space:]]+.+)$ ]] \
            && [[ "${BASH_REMATCH[2]}" == "$old" ]]; then
            out+="${BASH_REMATCH[1]}${new}${BASH_REMATCH[3]}"$'\n'
            found=1
        else
            out+="$line"$'\n'
        fi
    done <<< "$ct"
    (( found )) \
        || { warn "no set_pass line for $old in $PASSPHRASE_PATH; nothing to relabel there"; return 0; }
    printf '%s' "$out" | _pass_write
}

## ─── config mutation: append a repo block, rename a repo ─────────────
# init appends a repo block; rename rewrites a repo_name line. Both write
# atomically through a same-dir tempfile, preserving the file's mode, and touch
# only their target line, so a sibling tool's lines, comments and blanks are left
# in place. Everything else in the config is edited by hand.

# Write new whole-file content over the config atomically, preserving its mode.
_write_config() {
    local content="$1" tmp
    tmp=$(mktemp "${CONFIG_FILE}.XXXXXX") || die "cannot create a temp file beside $CONFIG_FILE"
    trap 'rm -f "$tmp"' EXIT
    trap 'rm -f "$tmp"; exit 130' INT TERM
    printf '%s' "$content" > "$tmp" || die "failed to write $tmp"
    if [[ -e "$CONFIG_FILE" ]]; then
        chmod --reference="$CONFIG_FILE" "$tmp" || die "failed to set mode on $tmp"
    else
        chmod 600 "$tmp" || die "failed to set mode on $tmp"
    fi
    mv -f "$tmp" "$CONFIG_FILE" || die "failed to update $CONFIG_FILE"
    trap - EXIT INT TERM
}

# Rewrite the repo_name block header in the config, preserving indentation and
# any trailing comment. Dies if the line is not found, so the directories are
# never silently left out of step with the config.
_update_repo_name() {
    local old="$1" new="$2"
    local found=0 line out=""
    while IFS= read -r line || [[ -n "$line" ]]; do
        if [[ "$line" =~ ^([[:space:]]*repo_name[[:space:]]+)([^[:space:]#]+)([[:space:]].*)?$ ]] \
            && [[ "${BASH_REMATCH[2]}" == "$old" ]]; then
            out+="${BASH_REMATCH[1]}${new}${BASH_REMATCH[3]:-}"$'\n'
            found=1
        else
            out+="$line"$'\n'
        fi
    done < "$CONFIG_FILE"
    (( found )) \
        || die "no 'repo_name $old' line in $CONFIG_FILE; drives renamed but the config was not updated"
    _write_config "$out"
}

# Append a new repo block: a backup_data line per resolved path, a backup_drives
# line, and the optional directives as commented templates. No archive line is
# templated: a new repo is archive-on by default. Paths are validated like
# backup_data before any write.
_append_repo_block() {
    local repo="$1" drives="$2"; shift 2
    local p abs base seen="" dq
    local -a body=("repo_name $repo")
    for p in "$@"; do
        abs=$(_resolve_home_path "$p")
        _require_absolute_path "backup_data folder" "$abs"
        base=$(basename "$abs")
        _validate_segment "backup_data folder name (basename)" "$base"
        case $'\n'"$seen" in
            *$'\n'"$base"$'\n'*) die "two folders named '$base' would collide at the archive root: $abs" ;;
        esac
        seen+="$base"$'\n'
        printf -v dq 'backup_data %q' "$abs"
        body+=("$dq")
    done
    body+=("backup_drives $drives")
    body+=("# keep daily=30 weekly=12 monthly=-1")
    body+=("# exclude '**/cache/**' '**/node_modules/**'     # denylist (shell-style globs)")
    body+=("# include_only '**/wanted'                          # repo-wide allowlist")
    body+=("# include_only_in \"\$HOME/sub\" '**/wanted'          # allowlist one folder, others whole")
    body+=("# compression auto,zstd,10")
    local existing block content
    existing=$(cat "$CONFIG_FILE")
    printf -v block '%s\n' "${body[@]}"
    # One blank line between the prior content and the new block.
    printf -v content '%s\n\n%s' "$existing" "$block"
    _write_config "$content"
}

# Prompt for repo $1's passphrase twice and store it through the atomic,
# recipient-preserving writer, then record the name so the caller can use it at
# once. Fails closed with no terminal so a non-interactive run never hangs, and
# requires the passphrase file to exist (the first passphrase is hand-created).
_store_pass() {
    local repo="$1"
    [[ "$(_pass_source_kind)" != none ]] \
        || die "no passphrase file at $PASSPHRASE_PATH yet; create it with your first repo's set_pass line, then init adds the rest"
    [[ -t 0 ]] \
        || die "no passphrase stored for $repo and no terminal to prompt; add a 'set_pass $repo' line to $PASSPHRASE_PATH, then re-run"
    local current repo_re
    current=$(_pass_cleartext) || die "could not read $PASSPHRASE_PATH"
    repo_re="${repo//./\\.}"
    if printf '%s\n' "$current" | grep -qE "^[[:space:]]*set_pass[[:space:]]+${repo_re}([[:space:]]|\$)"; then
        return 0                                     # already stored
    fi
    local p1 p2 line
    read -rsp "passphrase for $repo: " p1; printf '\n' >&2
    read -rsp "confirm: " p2;             printf '\n' >&2
    [[ -n "$p1" ]]       || die "empty passphrase; nothing stored"
    [[ "$p1" == "$p2" ]] || die "passphrases did not match; nothing stored"
    printf -v line 'set_pass %s %q' "$repo" "$p1"
    printf '%s\n%s\n' "$current" "$line" | _pass_write
    unset p1 p2
    PASS_NAMES["$repo"]=1
    say "stored a passphrase for $repo in $PASSPHRASE_PATH"
}

## ─── claim: take ownership of one drive's repos ──────────────────────
# The one place this tool uses sudo on a backup path. Deliberately not a chown -R
# of the whole mountpoint: a drive can hold someone else's files, and taking the
# lot would take those too. What borg needs is the mountpoint itself (its lock
# file is created beside the repo), any REPO_SUBDIR, and each repo directory in
# full, so that is exactly what is taken.
#
# Refuses rather than escalating wherever a chown cannot work or cannot be
# trusted: a path that is not the drive's own mountpoint, so a chown can never
# walk the root filesystem when a drive is not mounted where you thought; a
# read-only mount; a filesystem that carries no ownership. Requires a terminal,
# so a cron run can never sit on a sudo password prompt. Returns 1 on any refusal
# so the caller re-tests rather than assumes. It takes the drive and nothing
# else: naming a subset of the repos leaves the partly-claimed drive borg fails
# several steps into.
_claim_drive() {
    local drive="$1"
    local mount="$MOUNT_BASE/$drive"
    command -v findmnt >/dev/null 2>&1 || { warn "findmnt not found; cannot confirm what $mount is before changing ownership"; return 1; }
    command -v sudo    >/dev/null 2>&1 || { warn "sudo not found; take ownership of $mount by hand"; return 1; }
    mountpoint -q "$mount" || { warn "$(_not_mounted "$drive")"; return 1; }

    local fstype="" opts="" mnt=""
    read -r fstype opts mnt < <(findmnt -no FSTYPE,OPTIONS,TARGET -T "$mount" 2>/dev/null) || true
    [[ "$mnt" == "$mount" ]] \
        || { warn "$mount is not itself a mountpoint (it resolves to ${mnt:-an unknown mount}); refusing to change ownership there"; return 1; }
    _fs_blocker "$mount" "$fstype" "$opts" "" "so ownership cannot be changed" || return 1
    [[ -t 0 ]] || { warn "claiming $drive needs sudo and a terminal to confirm on; run it interactively"; return 1; }

    # What to take: the mountpoint, any REPO_SUBDIR, and every repo the config
    # puts on this drive. A repo directory that is not there yet is not chowned.
    local -a shallow=("$mount") deep=() names=()
    [[ -z "$REPO_SUBDIR" ]] || shallow+=("$mount/$REPO_SUBDIR")
    local r dir
    for r in ${REPOS[@]+"${REPOS[@]}"}; do
        _repo_has_drive "$r" "$drive" || continue
        dir=$(_repo_path "$drive" "$r")
        [[ -d "$dir" ]] && { deep+=("$dir"); names+=("$r"); }
    done

    # The paths share one prefix and the mountpoint is printed above them, so the
    # preview names the scope rather than spelling every path out: what is taken
    # shallow, what is taken in full, and by whom. The refusals above have already
    # established that $mount is this drive's own mountpoint.
    local owner; owner="$(id -un):$(id -gn)"
    say "claiming $drive for $owner, as root, under $mount:"
    if [[ -n "$REPO_SUBDIR" ]]; then
        say "  the mountpoint and $REPO_SUBDIR/ themselves, not their other contents"
    else
        say "  the mountpoint itself, not its other contents"
    fi
    if (( ${#names[@]} > 0 )); then
        say "  these repo directories, recursively: $(_csv "${names[@]}")"
    else
        say "  no configured repo of yours is there yet, so nothing else"
    fi
    local ans
    read -rp "take them? [y/N] " ans
    [[ "$ans" == [Yy] || "$ans" == [Yy][Ee][Ss] ]] || { warn "nothing changed"; return 1; }

    sudo chown "$owner" "${shallow[@]}" || { warn "could not take $mount"; return 1; }
    if (( ${#deep[@]} > 0 )); then
        sudo chown -R "$owner" "${deep[@]}" || { warn "could not take the repo directories on $drive"; return 1; }
    fi
    say "claimed $drive"
}

cmd_claim() {
    (( $# == 1 )) || die_usage "usage: $CMD claim <drive>"
    [[ "$1" != -* ]] || die_usage "usage: $CMD claim <drive>"
    need mountpoint
    local drive="$1"
    _validate_segment "drive" "$drive"
    parse_config
    _claim_drive "$drive" || exit 1
}

## ─── init: create the repos ──────────────────────────────────────────
_check_usb() {
    mountpoint -q "$MOUNT_BASE/$1" || { warn "$(_not_mounted "$1")"; return 1; }
}

_init_one() {
    local repo="$1" drive="$2" dir parent
    dir=$(_repo_path "$drive" "$repo")
    if [[ -d "$dir" ]]; then
        say "$dir already exists"
        return 0
    fi
    # Writability before borg, and before anything is created: an unwritable
    # target fails several steps into borg with a Python traceback otherwise.
    parent=$(_init_target_dir "$drive")
    if [[ -n "$(_first_unwritable "$parent")" ]]; then
        warn "$parent is not writable, so $repo cannot be created on $drive"
        _offer_claim "$drive" "$parent" || true
        [[ -z "$(_first_unwritable "$parent")" ]] || return 1
    fi
    if [[ -n "$REPO_SUBDIR" ]]; then
        mkdir -p "$MOUNT_BASE/$drive/$REPO_SUBDIR" \
            || { warn "could not create $MOUNT_BASE/$drive/$REPO_SUBDIR"; return 1; }
    fi
    local -x BORG_REPO="$dir"
    local pc; pc=$(_repo_passcommand "$repo"); [[ -n "$pc" ]] && local -x BORG_PASSCOMMAND="$pc"
    if borg init --encryption=repokey "$dir"; then
        say "initialised $dir"
    else
        warn "failed to initialise $dir"
        return 1
    fi
}

cmd_init() {
    ensure_borg; need mountpoint
    # <cmd> init [<repo> [<paths>...] [--drives <labels>...]]. A repo with paths
    # creates a new block; a repo without paths inits an existing repo; no repo
    # inits every configured repo. --drives sets the new block's drives (default
    # 'all'); everything after --drives is a drive label.
    local repo="" sawdrives=0
    local -a paths=() dr=()
    while (( $# )); do
        case "$1" in
            --drives)    sawdrives=1; shift ;;
            --drives=*)  die_usage "use '--drives d1 d2'; '--drives=...' is not supported" ;;
            -*)          die_usage "unknown flag: $1 (usage: $CMD init [<repo> [<paths>...] [--drives <labels>...]])" ;;
            *)
                if   (( sawdrives )); then dr+=("$1")
                elif [[ -z "$repo" ]]; then repo="$1"
                else paths+=("$1"); fi
                shift ;;
        esac
    done

    parse_config
    # Serialize against a concurrent archive/rename: init writes the config block
    # and creates borg repos, so it must not race another config writer.
    _acquire_lock
    local -a todo=()
    # A repo name not yet in the config means create it: with the folders given,
    # or as an awaiting-data repo when none are.
    local creating=0
    [[ -n "$repo" ]] && ! _repo_declared "$repo" && creating=1
    if (( creating )); then
        _validate_segment "repo name" "$repo"
        _reject_reserved_name "$repo"
        local drives="all"
        if (( sawdrives )); then
            (( ${#dr[@]} > 0 )) || die_usage "--drives needs at least one drive label"
            drives="${dr[*]}"
        fi
        _validate_drives_list "--drives" "$drives"
        # Every drive, mounted and writable, before anything is written. A repo
        # created on two of its four drives is a false redundancy belief: the
        # config says four copies exist and two do, and nothing says so again
        # until a restore needs the drive that was never written. pass-change and
        # rename already refuse a partial set for the same reason.
        local -a want=() missing=() pairs=()
        # shellcheck disable=SC2086  # validated space-separated labels; intentional split
        if [[ "$drives" == all ]]; then want=($ALL_DRIVES); else want=($drives); fi
        local d
        for d in "${want[@]}"; do
            mountpoint -q "$MOUNT_BASE/$d" || missing+=("$d")
        done
        (( ${#missing[@]} == 0 )) \
            || die "creating $repo needs every one of its drives mounted, so all copies start together; missing: ${missing[*]} (looked under $MOUNT_BASE)"
        for d in "${want[@]}"; do pairs+=("$d"$'\t'"$(_init_target_dir "$d")"); done
        _require_writable "creating $repo" "${pairs[@]}"
        # The passphrase is prompted after the block is written, so confirm that
        # can succeed before changing anything on disk.
        if [[ "$(_pass_source_kind)" == none ]]; then
            if [[ -z "$PASSPHRASE_PATH" ]]; then
                die "no passphrase file configured yet; create $CONFIG_FILE with a PASSPHRASE_PATH line pointing at a passphrase file (any name, gpg-encrypted or plaintext, chmod 600), put a 'set_pass $repo' line in that file, then re-run init"
            fi
            die "no passphrase file at $PASSPHRASE_PATH yet; create it with your first repo's set_pass line, then init adds the rest"
        fi
        [[ -t 0 ]] || die "creating $repo prompts for its passphrase; no terminal to prompt, so run it interactively"
        if (( ${#paths[@]} == 0 )); then
            # No folders given: a typo'd name would otherwise provision a junk repo
            # on every drive, so confirm the name and drives before writing.
            say "no folders given: this creates '$repo' as an awaiting-data repo on drives: $drives"
            local ans
            read -rp "create $repo? [y/N] " ans
            [[ "$ans" == [Yy] || "$ans" == [Yy][Ee][Ss] ]] || die "aborted; nothing changed"
        fi
        _append_repo_block "$repo" "$drives" "${paths[@]}"
        if (( ${#paths[@]} == 0 )); then
            say "added repo $repo to $CONFIG_FILE (awaiting data; add 'backup_data <path>' lines to its block)"
        else
            say "added repo $repo to $CONFIG_FILE"
        fi
        REPOS+=("$repo"); REPO_SEEN["$repo"]=1
        # The config line keeps 'all'; the in-memory drive list this init uses
        # must be the expanded pool, matching what backup_drives does at parse.
        REPO_DRIVES["$repo"]="${want[*]}"
        todo=("$repo")
    elif (( ${#paths[@]} > 0 )); then
        # Folders given for a name that is already a repo, or with no name at all.
        [[ -n "$repo" ]] || die_usage "creating a repo needs a name: $CMD init <repo> <path>..."
        die "repo $repo is already in $CONFIG_FILE; edit its block by hand, or pick a new name"
    else
        (( ! sawdrives )) || die_usage "--drives only applies when creating a repo"
        _require_repos_valid
        if [[ -n "$repo" ]]; then
            todo=("$repo")
        else
            todo=("${REPOS[@]}")
        fi
    fi

    # A repo's passphrase is born at init: store a missing one (prompted, failing
    # closed with no terminal) rather than refusing to proceed. Presence comes
    # from the names-only child, so no value enters this process here.
    local r
    for r in "${todo[@]}"; do
        _repo_has_pass "$r" || _store_pass "$r"
    done

    local drive available configured rc=0
    for r in "${todo[@]}"; do
        say "initialising repo $r..."
        available=(); configured=0
        # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
        for drive in ${REPO_DRIVES[$r]}; do
            configured=$(( configured + 1 ))
            if _check_usb "$drive"; then available+=("$drive"); fi
        done
        if (( ${#available[@]} == 0 )); then
            warn "no drives available for $r"
            rc=1
            continue
        fi
        for drive in "${available[@]}"; do
            _init_one "$r" "$drive" || rc=1
        done
        # An existing repo may legitimately be completed one drive at a time, but
        # the gap should not be silent: until every drive has a copy, the config
        # promises redundancy the disks do not have.
        if (( ${#available[@]} < configured )); then
            warn "$r is not on all ${configured} of its drives yet; re-run '$CMD init $r' with the missing drive(s) mounted"
            rc=1
        fi
    done
    exit "$rc"
}

## ─── archive: the default action ─────────────────────────────────────
_fmt_duration() {
    local secs="$1" h m s
    h=$(( secs / 3600 )); m=$(( (secs % 3600) / 60 )); s=$(( secs % 60 ))
    if   (( h > 0 )); then printf '%dh%dm%ds' "$h" "$m" "$s"
    elif (( m > 0 )); then printf '%dm%ds' "$m" "$s"
    else                   printf '%ds' "$s"
    fi
}

# Bytes to a short human size via numfmt SI (decimal, powers of 1000), dropping
# a trailing .0 so a round value reads 2MB rather than 2.0MB.
_fmt_size() {
    local s
    s=$(numfmt --to=si --suffix=B --format='%.1f' "$1" 2>/dev/null) || s="${1}B"
    printf '%s' "${s/.0/}"
}

# One summary group as ", <count> <label> (name, name)", or nothing when the
# list is empty, so an unused category drops out of the line cleanly.
_group() {
    local label="$1"; shift
    (( $# > 0 )) || return 0
    printf ', %d %s (%s)' "$#" "$label" "$(_csv "$@")"
}

# Confirm with borg that a repo's allowlist actually bites before writing an
# archive under it. Args: <repo> <pattern opt>... -- <source>... ; BORG_REPO is
# already set by the caller. Returns 0 when the allowlist both keeps something
# and drops something, 1 otherwise, warning with the reason.
#
# borg's matcher is the only authority on what these patterns do, so this asks
# it rather than reasoning about the globs. `create --dry-run` reads no file data
# and does not unlock the repo key, so the cost is one metadata walk of the
# sources and no passphrase use: no agent prompt, no hardware token touch.
# `--list` writes its per-file status to stderr, not stdout, hence the swap
# below. The archive name is never created, so reusing one is harmless.
#
# Two outcomes are refused. Nothing dropped means the allowlist is inert and the
# folder goes to the drive whole, which is a privacy failure that reports as
# success. Nothing kept means an empty archive; catching it here means nothing is
# written and no retention decision hangs on it.
_assert_filter_bites() {
    local repo="$1"; shift
    local -a popts=() srcs=()
    local a seen=0
    for a in "$@"; do
        if   (( seen ));       then srcs+=("$a")
        elif [[ "$a" == -- ]]; then seen=1
        else                        popts+=("$a"); fi
    done
    (( ${#srcs[@]} > 0 )) || return 0
    local out rc=0
    out=$(borg create --dry-run --list ${popts[@]+"${popts[@]}"} "::{now}" "${srcs[@]}" 2>&1 >/dev/null) || rc=$?
    if (( rc != 0 )); then
        # A dry run that cannot even walk the sources says nothing about the
        # patterns. Do not block the backup on it; the post-create checks stand.
        warn "could not dry-run the filter for $repo (borg exited $rc); the allowlist was not verified"
        return 0
    fi
    local kept dropped
    kept=$(printf '%s\n' "$out" | grep -c '^- ') || true
    dropped=$(printf '%s\n' "$out" | grep -c '^x ') || true
    if (( dropped == 0 )); then
        warn "$repo has an allowlist but it dropped nothing, so the folders would be archived whole; the globs are matching no path borg sees"
        warn "borg matches the source path with its leading slash removed, so a folder at /home/you/proj is matched as home/you/proj/...; check the globs with: borg create --list --dry-run"
        return 1
    fi
    if (( kept == 0 )); then
        warn "$repo has an allowlist and it kept nothing, so the archive would be empty; check the globs with: borg create --list --dry-run"
        return 1
    fi
    return 0
}

# Append one repo-wide glob list to the caller's pattern array as '<sign> sh:'
# patterns. One function for both signs because exclude and include_only must
# anchor identically: an edit reaching one loop and not the other would change
# what a backup holds and say nothing. $1 names the caller's array, $2 is '+' or
# '-', $3 the newline-joined globs, the rest the matcher roots.
_add_repo_wide_patterns() {
    local -n _out="$1"
    local sign="$2" globs="$3"; shift 3
    local p mroot
    while IFS= read -r p; do
        [[ -n "$p" ]] || continue
        if [[ "$p" == /* ]]; then
            _out+=( --pattern "$sign sh:${p#/}" )
        else
            for mroot in "$@"; do _out+=( --pattern "$sign sh:$mroot/**/$p" ); done
        fi
    done <<< "$globs"
}

# One repo to one drive. Returns 0 on archived-and-verified, 1 if the archive was
# not created, 2 if it was created but failed its quick verification.
_archive_one_drive() {
    local repo="$1" drive="$2"
    local mount="$MOUNT_BASE/$drive"
    if ! mountpoint -q "$mount"; then
        warn "$(_not_mounted "$drive")"
        _note_issue "$repo on $drive: not mounted at $mount"
        return 1
    fi
    local -x BORG_REPO; BORG_REPO=$(_repo_path "$drive" "$repo")
    if [[ ! -d "$BORG_REPO" ]]; then
        warn "no $repo repo on $drive"
        _note_issue "$repo on $drive: no repo directory at $BORG_REPO"
        return 1
    fi
    # borg's first act on a write is to create a lock file inside the repo dir,
    # so an unwritable repo fails with a Python traceback several steps in. The
    # preflight already refused the run if no drive had a writable repo; this
    # catches the mixed case, where some drives are fine.
    local blocked; blocked=$(_first_unwritable "$BORG_REPO")
    if [[ -n "$blocked" ]]; then
        warn "$repo on $drive is not writable ($blocked)"
        _unwritable_hint "$BORG_REPO" "$drive"
        _note_issue "$repo on $drive: not writable ($blocked)"
        return 1
    fi
    local pc; pc=$(_repo_passcommand "$repo"); [[ -n "$pc" ]] && local -x BORG_PASSCOMMAND="$pc"

    # Each folder is anchored with /./ so it lands at the archive root under
    # its own basename, regardless of where it lives on disk. The same loop
    # records each folder's matcher root, because the two are not the same path
    # and the filtering below needs the second one.
    local -a srcs=() popts=() roots=()
    local p key g mroot
    while IFS= read -r p; do
        [[ -n "$p" ]] || continue
        srcs+=( "$(dirname "$p")/./$(basename "$p")" )
        roots+=( "${p#/}" )
    done <<< "${REPO_SRC[$repo]}"

    # Filtering. borg does the matching; this assembles one ordered list of
    # '+'/'- sh:' patterns (first match wins, unmatched kept). This is the one
    # place the matcher's addressing is written down; everything else points here.
    #
    # THE MATCHER ROOT. borg matches the source path made relative, i.e. the
    # absolute path with its leading slash off: /home/john/proj reaches the
    # matcher as home/john/proj/... . The /./ anchor changes only the name the
    # item is stored under, never what the matcher sees, so a pattern written
    # against the stored name matches nothing. That failure is silent in the
    # dangerous direction: an allowlist whose '-' never fires archives its folder
    # whole and reports success. Every pattern here is anchored on a folder's
    # matcher root, and _assert_filter_bites has borg confirm it before create.
    #
    # ORDER. Every exclude is a plain drop emitted before any '+', so an explicit
    # exclude always beats an allowlist. Then either a repo-wide allowlist ('+'
    # per glob, global '- sh:**' for the rest) or a folder-scoped one ('+' on
    # that folder's matcher root, '- sh:<root>/**' for its rest, other folders
    # left whole). The two scopes are mutually exclusive, checked at parse.
    #
    # DEPTH, deliberately different per directive. exclude and include_only name
    # no folder, so a bare glob means "anywhere in this repo" and is emitted per
    # root behind '**/', which borg matches at zero directories as well as many.
    # include_only_in has named its folder, so its globs stay anchored there;
    # widening them would let 'src/**' keep a vendored src/ levels down. A glob
    # written as an absolute path is already a matcher path once the leading
    # slash is off, so it passes through unprefixed.
    _add_repo_wide_patterns popts '-' "${REPO_EXCLUDES[$repo]:-}" ${roots[@]+"${roots[@]}"}
    if [[ -n "${REPO_INCLUDES[$repo]:-}" ]]; then
        _add_repo_wide_patterns popts '+' "${REPO_INCLUDES[$repo]}" ${roots[@]+"${roots[@]}"}
        popts+=( --pattern '- sh:**' )
    else
        while IFS= read -r p; do
            [[ -n "$p" ]] || continue
            key="$repo"$'\n'"$p"
            [[ -n "${REPO_FINCLUDE_GLOBS[$key]:-}" ]] || continue
            mroot="${p#/}"
            while IFS= read -r g; do
                [[ -n "$g" ]] && popts+=( --pattern "+ sh:$mroot/$g" )
            done <<< "${REPO_FINCLUDE_GLOBS[$key]}"
            popts+=( --pattern "- sh:$mroot/**" )
        done <<< "${REPO_SRC[$repo]}"
    fi

    # Once per repo per run: the answer is a property of the config and the
    # source tree, not of the drive being written.
    if [[ -n "${REPO_INCLUDES[$repo]:-}${REPO_HAS_FINCLUDE[$repo]:-}" ]] \
       && [[ -z "${RUN_REPO_FILTER_CHECKED[$repo]:-}" ]]; then
        RUN_REPO_FILTER_CHECKED["$repo"]=1
        if ! _assert_filter_bites "$repo" ${popts[@]+"${popts[@]}"} -- "${srcs[@]}"; then
            _note_issue "$repo on $drive: allowlist matched nothing, so nothing was archived"
            return 2
        fi
    fi

    # borg's default compression is lz4; a `compression` directive overrides it
    # with the user's spec, passed through for borg to validate and apply.
    [[ -n "${REPO_COMPRESSION[$repo]:-}" ]] && popts+=( --compression "${REPO_COMPRESSION[$repo]}" )

    # borg's rc: 0 clean, 1 warnings (a source file changed or vanished mid-run,
    # common for live dotfiles) with the archive still written, 2+ a real error.
    # Treat warnings as success so a changing profile isn't a failure. Progress
    # goes to stderr, so gate it on stderr being a terminal: interactive runs get
    # feedback, cron logs stay clean.
    local -a progress=()
    [[ -t 2 ]] && progress=( --progress )
    local create_rc=0 create_json
    create_json=$(borg create --json ${progress[@]+"${progress[@]}"} ${popts[@]+"${popts[@]}"} "::{now}" "${srcs[@]}") || create_rc=$?
    if (( create_rc >= 2 )); then
        warn "borg create failed (rc=$create_rc) for $repo on $drive"
        # borg's own error reached stderr above this line, unredirected, so any
        # remedy printed before it has already scrolled away. The preflight
        # cleared this repo minutes ago; re-test, because a drive that remounts
        # read-only mid-run lands here.
        blocked=$(_first_unwritable "$BORG_REPO")
        if [[ -n "$blocked" ]]; then
            warn "$blocked is not writable now, which is very likely the error above"
            _unwritable_hint "$BORG_REPO" "$drive"
        fi
        _note_issue "$repo on $drive: borg create failed (rc=$create_rc)"
        return 1
    fi
    if (( create_rc == 1 )); then
        warn "borg create finished with warnings for $repo on $drive; archive was still created"
    fi

    # borg's own figures: this snapshot's deduplicated size is how much the repo
    # grew, and unique_csize is the repo's on-disk total. Best-effort; a parse
    # miss just drops the size from the line. borg 2.0 renames both fields.
    local added_bytes repo_bytes size_note=""
    added_bytes=$(printf '%s' "$create_json" | sed -n 's/.*"deduplicated_size"[^0-9]*\([0-9]\{1,\}\).*/\1/p')
    repo_bytes=$(printf '%s' "$create_json"  | sed -n 's/.*"unique_csize"[^0-9]*\([0-9]\{1,\}\).*/\1/p')
    if [[ -n "$added_bytes" ]]; then
        # The same logical data goes to every drive, so count each repo's growth
        # once; summing per drive would multiply the run total by the drive count.
        if [[ -z "${RUN_REPO_DEDUP_COUNTED[$repo]:-}" ]]; then
            RUN_DEDUP_BYTES=$(( RUN_DEDUP_BYTES + added_bytes ))
            RUN_REPO_DEDUP_COUNTED["$repo"]=1
        fi
        if [[ -n "$repo_bytes" ]]; then
            size_note=" (+$(_fmt_size "$added_bytes"), repo $(_fmt_size "$repo_bytes"))"
        else
            size_note=" (+$(_fmt_size "$added_bytes"))"
        fi
    fi

    # An archive can come out empty from a wrong include_only glob or from source
    # folders that hold nothing, and an empty archive is structurally valid, so
    # borg and the verification below both accept it. create --json already
    # reported the file count, so this costs nothing; do not replace it with a
    # listing call. A folder-scoped include_only that matches nothing only
    # empties its one folder, which a whole-archive count cannot see.
    local empty_archive=0 nfiles
    nfiles=$(printf '%s' "$create_json" | sed -n 's/.*"nfiles"[^0-9]*\([0-9]\{1,\}\).*/\1/p')
    if [[ -z "$nfiles" ]]; then
        warn "could not read nfiles from borg create --json for $repo on $drive; the empty-archive check did not run"
    elif (( nfiles == 0 )); then
        warn "archived $repo on $drive but the archive is EMPTY (0 files): check the include_only globs with 'borg create --list --dry-run', or confirm the folders are not empty"
        empty_archive=1
    fi

    # Verify before retention, not after. Prune deletes archives and compact
    # frees their space, so running them first would destroy known-good history
    # on the assumption that the archive just written is good, and test that
    # assumption afterwards. Nothing is pruned unless the new archive both
    # verifies and holds files.
    local check_out="" check_rc=0
    if [[ -n "$pc" ]]; then
        check_out=$(borg check --archives-only --last 1 2>&1) || check_rc=$?
    else
        # With no passcommand borg asks for the passphrase on stderr, so
        # capturing stderr here would hide the prompt and the run would look hung.
        borg check --archives-only --last 1 || check_rc=$?
    fi
    if (( check_rc != 0 )); then
        warn "archived $repo on $drive but the new archive FAILED verification; run: $CMD check $repo"
        if [[ -n "$check_out" ]]; then warn "$check_out"; fi
        warn "retention skipped for $repo on $drive; no archive was pruned"
        _note_issue "$repo on $drive: archive written but failed verification"
        return 2
    fi
    if (( empty_archive )); then
        # Structurally valid but empty: report unverified so it is not counted as
        # a good backup, and keep retention off so it cannot rotate a real one out.
        warn "retention skipped for $repo on $drive; no archive was pruned"
        _note_issue "$repo on $drive: archive written but empty, so it is not counted as a backup"
        return 2
    fi

    # Pruned only if the repo has a keep line; compact frees the space prune
    # marks. borg 2.0 change point: prune there requires an archive glob.
    if [[ -n "${SEEN_KEEP[$repo]+x}" ]]; then
        local kl="${KEEP_LAST[$repo]:-$KEEP_LAST_DEFAULT}"
        if (( kl != 0 )); then
            borg prune --keep-last "$kl" || warn "prune failed for $repo on $drive"
        else
            borg prune \
                --keep-hourly   "${KEEP_HOURLY[$repo]:-$KEEP_HOURLY_DEFAULT}" \
                --keep-daily    "${KEEP_DAILY[$repo]:-$KEEP_DAILY_DEFAULT}" \
                --keep-weekly   "${KEEP_WEEKLY[$repo]:-$KEEP_WEEKLY_DEFAULT}" \
                --keep-monthly  "${KEEP_MONTHLY[$repo]:-$KEEP_MONTHLY_DEFAULT}" \
                --keep-yearly   "${KEEP_YEARLY[$repo]:-$KEEP_YEARLY_DEFAULT}" \
                || warn "prune failed for $repo on $drive"
        fi
        borg compact || warn "compact failed for $repo on $drive"
    fi

    say "archived $repo on $drive$size_note"
    return 0
}

# Set to 1 once a run has printed its summary, so the EXIT trap stays quiet on a
# normal exit. The trap must not disturb the exit status, so it never calls exit
# and never returns non-zero.
_ARCHIVE_REPORTED=0
_archive_exit_notice() {
    local rc=$?
    # Only when stdout is not a terminal. The line exists so a redirected run can
    # be read for its outcome from that one stream: a fatal exit prints its reason
    # on stderr and nothing on stdout, which would otherwise be indistinguishable
    # from a run that never happened.
    if (( rc != 0 && _ARCHIVE_REPORTED == 0 )) && [[ ! -t 1 ]]; then
        printf '%s failed before it could report; the reason is on stderr above (exit %d)\n' "$CMD" "$rc"
    fi
    return 0
}

# One repo across its drives. The return code is the repo's outcome for the
# summary, not a plain pass/fail: 0 verified clean on every drive, 3 backed up
# (verified on at least one) but a drive was missing or failed verification, 4 an
# archive was written but none verified anywhere, 1 no archive written anywhere.
_archive_repo() {
    local repo="$1"
    # borg treats a missing source path as a warning, and the create step maps
    # borg's warning code to success so a dotfile changing mid-run is not a
    # failure. Together those would let a deleted or unmounted folder produce a
    # short archive that reports as good and then rotates a real one out under
    # retention. Refuse the repo instead: the run loses one backup, where
    # proceeding loses an old one.
    local missing_src=() s
    while IFS= read -r s; do
        [[ -n "$s" ]] || continue
        [[ -e "$s" ]] || missing_src+=("$s")
    done <<< "${REPO_SRC[$repo]}"
    if (( ${#missing_src[@]} > 0 )); then
        warn "repo $repo not archived: configured folder(s) missing: $(_csv "${missing_src[@]}")"
        warn "fix the path in $CONFIG_FILE or mount what holds it; existing archives are untouched"
        _note_issue "$repo: configured folder(s) missing: $(_csv "${missing_src[@]}")"
        _RUN_LAST=block
        return 1
    fi
    if [[ -n "$_RUN_LAST" ]]; then say ""; fi
    _RUN_LAST=block
    say "archiving repo $repo..."
    local start drive r any_clean=0 any_unverified=0 any_missing=0
    start=$(date +%s)
    # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
    for drive in ${REPO_DRIVES[$repo]}; do
        r=0
        _archive_one_drive "$repo" "$drive" || r=$?
        case "$r" in
            0) any_clean=1 ;;
            2) any_unverified=1 ;;
            *) any_missing=1 ;;
        esac
    done
    local dur; dur=$(_fmt_duration $(( $(date +%s) - start )))
    if (( any_clean )); then
        say "repo $repo done in $dur"
        if (( any_unverified || any_missing )); then return 3; fi
        return 0
    fi
    if (( any_unverified )); then
        warn "repo $repo: an archive was written but failed verification on every drive; run: $CMD check $repo"
        return 4
    fi
    warn "repo $repo not archived to any drive"
    return 1
}

# Refuse a run that cannot succeed, before the passphrase is touched. A repo that
# would archive is archive-on and holds backup_data. Mounted is not enough:
# Mounted is not enough: archive has no read-only fallback the way check and
# extract do. For a GPG passphrase file the passphrase is an agent prompt or a
# hardware-token touch, so nothing is solicited for work that cannot succeed. When the repos on a drive
# are simply owned by someone else, offer to claim it and re-scan once; with no
# terminal there is no offer, and the run fails with the remedy printed.
# Returns 0 when at least one repo would archive, 1 when none would.
_archive_preflight_drives() {
    local r d rp has_target=0 any_drive=0 any_writable=0 offered=0 claimed=0 ever_claimed=0
    local -a want=() unwritable=()
    local -A unwritable_drives=()
    while :; do
        has_target=0; any_drive=0; any_writable=0; claimed=0
        want=(); unwritable=(); unwritable_drives=()
        for r in "$@"; do
            _repo_archives "$r" || continue
            [[ -n "${REPO_SRC[$r]:-}" ]] || continue
            has_target=1
            # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
            for d in ${REPO_DRIVES[$r]}; do
                want+=("$d")
                mountpoint -q "$MOUNT_BASE/$d" || continue
                any_drive=1
                rp=$(_repo_path "$d" "$r")
                [[ -d "$rp" ]] || continue     # reported per drive by _archive_one_drive
                if [[ -z "$(_first_unwritable "$rp")" ]]; then
                    any_writable=1
                else
                    unwritable+=("$r on $d")
                    unwritable_drives["$d"]=1
                fi
            done
        done
        (( ${#unwritable[@]} > 0 )) || break
        warn "repo directory not writable: $(_csv "${unwritable[@]}")"
        (( offered )) && break
        offered=1
        # The cause is a property of the drive, not the repo, so several repos on
        # one drive share one offer and one remedy.
        for d in "${!unwritable_drives[@]}"; do
            if _offer_claim "$d" "$MOUNT_BASE/$d"; then claimed=1; ever_claimed=1; fi
        done
        (( claimed )) || break
    done
    if (( has_target && ! any_drive )); then
        local uniq
        uniq=$(printf '%s\n' "${want[@]}" | awk '!seen[$0]++' | paste -sd' ' -)
        die "no drives mounted for the repo(s) to back up (looked for: ${uniq}); plug one in and re-run"
    fi
    (( has_target && any_drive && ! any_writable )) \
        && die "no writable repo on any mounted drive; nothing can be archived, so the passphrase was not touched"
    (( has_target )) || return 1
    # A claim ran, so its output is finished with; start the run's own on a fresh
    # line rather than running the two together.
    if (( ever_claimed )); then say ""; fi
    return 0
}

cmd_archive() {
    (( $# <= 1 )) || die_usage "$CMD archives all repos, or one named repo; run '$CMD --help' for the subcommands"
    # Every ordinary outcome of a run, including total failure, ends with a
    # summary line on stdout; a fatal exit printed nothing there at all, so a run
    # that died on a config or passphrase problem was indistinguishable from a
    # run that never happened. This is the backup log for a cron-driven tool.
    _ARCHIVE_REPORTED=0
    trap '_archive_exit_notice' EXIT
    # First line of the run, before the config is even read, so a log identifies
    # what produced it even when the run dies in config parsing or the preflight.
    say "$(_identity)"
    local only=""
    (( $# == 1 )) && only="$1"
    ensure_borg; need mountpoint
    parse_config
    _require_repos_valid
    if [[ -n "$only" ]]; then
        _repo_declared "$only" \
            || die_usage "$only is not a configured repo or a known subcommand; run '$CMD --help' for the list"
    fi

    local -a run_repos=()
    if [[ -n "$only" ]]; then run_repos=("$only"); else run_repos=("${REPOS[@]}"); fi

    local has_target=0
    _archive_preflight_drives "${run_repos[@]}" && has_target=1

    # The lock comes before the passphrase, not after: the preflight below runs
    # the _pass-names child, which for a GPG file means an agent prompt or a
    # hardware-token touch, and a second concurrent run should be turned away
    # before it makes you touch anything. Everything above is read-only, so
    # nothing is lost by holding the lock from here.
    _acquire_lock

    # borg reads these from the environment and they override anything decided
    # here, so an exported leftover from an earlier shell would supply the
    # passphrase while the run reports it came from the file or a prompt. Not
    # neutralised, since setting one deliberately is legitimate, but never silent.
    if [[ -n "${BORG_PASSPHRASE:-}" || -n "${BORG_PASSPHRASE_FD:-}" || -n "${BORG_PASSCOMMAND:-}" ]]; then
        warn "a borg passphrase variable is set in the environment and takes precedence over $CONFIG_FILE's passphrase source"
    fi

    (( has_target )) && _archive_pass_preflight "${run_repos[@]}"

    RUN_DEDUP_BYTES=0
    RUN_REPO_DEDUP_COUNTED=()
    RUN_REPO_FILTER_CHECKED=()
    _RUN_LAST=""
    local script_start repo r rc=0
    # Each repo lands in one outcome by name. A partial repo is listed in both
    # backed_up and partial: it has a usable archive on a drive and is also
    # flagged for the drive it missed or that failed.
    local -a backed_up=() partial=() unverified=() failed=() awaiting_data=()
    script_start=$(date +%s)
    for repo in "${run_repos[@]}"; do
        if ! _repo_archives "$repo"; then
            if [[ "$_RUN_LAST" == block ]]; then say ""; fi
            say "skipping archive-off repo $repo"
            _RUN_LAST=skip
            continue
        fi
        if [[ -z "${REPO_SRC[$repo]:-}" ]]; then
            if [[ "$_RUN_LAST" == block ]]; then say ""; fi
            say "skipping $repo: no backup_data yet (awaiting data)"
            _RUN_LAST=skip
            awaiting_data+=("$repo")
            continue
        fi
        # No stored passphrase: skipped, warned and counted via PASS_SKIPPED in
        # the preflight. In prompt mode there is nothing to store, so run it.
        _pass_prompt_mode || _repo_has_pass "$repo" || continue
        r=0
        _archive_repo "$repo" || r=$?
        case "$r" in
            0) backed_up+=("$repo") ;;
            3) backed_up+=("$repo"); partial+=("$repo") ;;
            4) unverified+=("$repo") ;;
            *) failed+=("$repo") ;;
        esac
    done

    # An error run is anything short of a verified archive on every drive of
    # every archived repo. Archive-off and awaiting-data repos are intentional.
    if (( ${#failed[@]} > 0 || ${#unverified[@]} > 0 || ${#partial[@]} > 0 || ${#PASS_SKIPPED[@]} > 0 )); then
        rc=1
    fi

    if (( ${#backed_up[@]} == 0 && ${#failed[@]} == 0 && ${#unverified[@]} == 0 && ${#PASS_SKIPPED[@]} == 0 && ${#awaiting_data[@]} == 0 )); then
        if [[ -n "$_RUN_LAST" ]]; then say ""; fi
        if [[ -n "$only" ]]; then
            say "$only is archive-off; set 'archive yes' or remove its 'archive no' line in $CONFIG_FILE"
        else
            say "all configured repos are archive-off; nothing to back up"
        fi
        _ARCHIVE_REPORTED=1
        exit 0
    fi

    local dur ts added noun=repos backed_names="" extra=""
    dur=$(_fmt_duration $(( $(date +%s) - script_start )))
    ts=$(date '+%a %d %b, %H:%M')
    added=$(_fmt_size "$RUN_DEDUP_BYTES")
    (( ${#backed_up[@]} == 1 )) && noun=repo
    (( ${#backed_up[@]} > 0 )) && backed_names=" ($(_csv "${backed_up[@]}"))"
    extra+=$(_group failed     "${failed[@]}")
    extra+=$(_group unverified "${unverified[@]}")
    extra+=$(_group partial    "${partial[@]}")
    extra+=$(_group skipped    "${PASS_SKIPPED[@]}")
    # An archive-off repo is a standing config fact, already said once per repo
    # in the body above, so it is not restated in the line a cron log is read for.
    extra+=$(_group "without backup_data" "${awaiting_data[@]}")
    if [[ -n "$_RUN_LAST" ]]; then say ""; fi
    # On stdout, immediately above the summary: this is the answer to "why was
    # that repo partial", and it has to reach whoever reads the stream the
    # verdict lands in. Only on an error run, so a clean run keeps its one line.
    if (( rc != 0 && ${#RUN_ISSUES[@]} > 0 )); then
        local issue
        for issue in "${RUN_ISSUES[@]}"; do say "  $issue"; done
        say ""
    fi
    if (( rc == 0 )); then
        printf 'backed up and verified %d %s%s, +%s in %s%s, %s\n' \
            "${#backed_up[@]}" "$noun" "$backed_names" "$added" "$dur" "$extra" "$ts"
    else
        printf 'completed with errors: backed up and verified %d %s%s, +%s in %s%s, %s\n' \
            "${#backed_up[@]}" "$noun" "$backed_names" "$added" "$dur" "$extra" "$ts"
    fi
    _ARCHIVE_REPORTED=1
    exit "$rc"
}

## ─── extract: restore ────────────────────────────────────────────────
# Said by both extract routes that can write over live folders, so it is one
# string: the whole-suite form and the named-repo form must not drift apart.
readonly IN_PLACE_WARNING='in-place restore overwrites files at their original locations'

# True when any of the repo's folders carries a restore_to line. Kept as a scan
# rather than a second map so the two cannot disagree.
_repo_has_restore_to() {
    local repo="$1" src
    while IFS= read -r src; do
        [[ -n "$src" ]] || continue
        [[ -z "${REPO_RESTORE_TO["$repo"$'\n'"$src"]:-}" ]] || return 0
    done <<< "${REPO_SRC[$repo]:-}"
    return 1
}

# Several helpers below build borg's environment in a subshell so BORG_REPO,
# BORG_PASSCOMMAND and RO_LOCKOPT cannot reach the next drive. shellcheck reports
# that as SC2030/SC2031, which is the design rather than a defect, so those
# functions carry a one-line disable; it is never disabled globally.

# True when the spec makes the caller list the archives before extracting, so
# with no stored passphrase the second borg prompt is expected, not a retry.
_spec_lists_first() {
    local spec="$1"
    [[ -z "$spec" || "$spec" =~ ^-[0-9]+$ ]]
}

_require_restore_path() {
    [[ -n "$RESTORE_PATH" ]] || die "RESTORE_PATH is not set in $CONFIG_FILE"
    [[ -d "$RESTORE_PATH" ]] || die "RESTORE_PATH '$RESTORE_PATH' is not a directory; create it first"
}

# `borg list --short` with borg's own reason surfaced rather than left on a
# stderr stream a multi-repo run scrolls past: a wrong passphrase and an
# archive-less repo otherwise both arrive several layers up as "no usable
# archive", which sends you looking at the wrong thing. Only captured when a
# passphrase is already in the environment; with none, borg asks on stderr, and
# redirecting that would hide the prompt and the run would look hung.
_list_archives() {
    local what="$1"; shift
    local rc=0 errf="" reason
    if [[ -n "${BORG_PASSCOMMAND:-}${BORG_PASSPHRASE:-}${BORG_PASSPHRASE_FD:-}" ]]; then
        errf=$(mktemp) || errf=""
    fi
    if [[ -n "$errf" ]]; then
        # BORG_SHOW_SYSINFO=no is what makes the line below the reason. borg logs
        # its own message first and, on an unexpected exception, a traceback and
        # then an environment dump, whose last line is 'SSH_ORIGINAL_COMMAND:
        # None', so the tail of the stream is the dump and never the cause. With
        # the dump off, the last line is the message on an ordinary error and the
        # exception itself on a crash, which is the line worth showing either way.
        BORG_SHOW_SYSINFO=no borg list ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --short "$@" 2>"$errf" || rc=$?
        if (( rc != 0 )); then
            reason=$(grep -v '^[[:space:]]*$' "$errf" | tail -n1)
            warn "could not list $what: ${reason:-borg exited $rc}"
        fi
        rm -f "$errf"
    else
        borg list ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --short "$@" || rc=$?
        (( rc == 0 )) || warn "could not list $what (borg exited $rc)"
    fi
    return "$rc"
}

# Resolve a spec to a concrete archive name, with BORG_REPO already set. Empty
# or -1 is the latest; -N is N back; anything else is a literal archive name,
# passed through unverified, since checking it would cost another borg call and,
# with no stored passphrase, another prompt per drive.
_resolve_archive() {
    local what="$1" spec="$2"
    if [[ -z "$spec" || "$spec" == "-1" ]]; then
        local latest
        latest=$(_list_archives "$what" --last 1) || return 1
        [[ -n "$latest" ]] || { warn "no archives found in $what, aborting"; return 1; }
        printf '%s\n' "$latest"
    elif [[ "$spec" =~ ^-([0-9]+)$ ]]; then
        local n="${BASH_REMATCH[1]}"
        (( n >= 1 )) || { warn "invalid -N: '$spec' (use -1, -2, ...)"; return 1; }
        local listing arr=()
        listing=$(_list_archives "$what" --last "$n") || return 1
        mapfile -t arr <<< "$listing"
        [[ -n "${arr[-1]:-}" ]] || unset 'arr[-1]'
        if (( ${#arr[@]} < n )); then
            warn "$what has only ${#arr[@]} archive(s); cannot extract $spec"
            return 1
        fi
        printf '%s\n' "${arr[0]}"
    else
        printf '%s\n' "$spec"
    fi
}

# Report what actually arrived under $dest for the archive-relative paths asked
# for. borg's positional paths start at a source folder's own basename, because
# every source is anchored with /./ at create time, so a typo matches nothing
# while borg still reports success; check what landed rather than the exit status.
# $2 is the hint printed after a miss. Returns 1 when nothing arrived.
_report_arrivals() {
    local dest="$1" hint="$2"; shift 2
    local p arrived=() missing=()
    for p in "$@"; do
        if [[ -e "$dest/$p" ]]; then arrived+=("$p"); else missing+=("$p"); fi
    done
    if (( ${#missing[@]} > 0 )); then
        warn "nothing was restored for: $(_csv "${missing[@]}")"
        warn "$hint"
    fi
    (( ${#arrived[@]} > 0 )) || return 1
    say "restored into $dest:"
    for p in "${arrived[@]}"; do say "  $p"; done
    return 0
}

# Config-free extract: name the repo by path, the archive lands in the current
# directory, and borg prompts for the passphrase since nothing is stored.
_borgex_path() {
    local repo="$1" spec="$2"; shift 2
    local paths=("$@")
    if [[ ! -d "$repo" ]]; then
        if [[ "$repo" == /* ]]; then die "no directory at $repo"
        else die "no directory at $repo (relative to the current directory, $PWD)"; fi
    fi
    local resolved; resolved=$(readlink -f -- "$repo") || die "cannot resolve path $repo"
    local -x BORG_REPO="$resolved"
    _set_read_lock "$resolved"
    if [[ -z "${BORG_PASSPHRASE:-}" && -z "${BORG_PASSPHRASE_FD:-}" && -z "${BORG_PASSCOMMAND:-}" ]]; then
        local how=once
        _spec_lists_first "$spec" && how=twice
        _prompt_notice "a repo named by path has nothing stored" "$resolved" "$how"
    fi
    local archive; archive=$(_resolve_archive "$resolved" "$spec") || return 1
    local dest="$PWD"
    if (( ${#paths[@]} > 0 )); then
        say "extracting ${#paths[@]} path(s) from $resolved (archive: $archive) into $dest..."
        if borg extract ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --progress "::$archive" "${paths[@]}"; then
            _report_arrivals "$dest" "a path starts at a source folder's own name, case included; list the archive with: borg list '$resolved::$archive'" "${paths[@]}" \
                || { warn "nothing was restored from $resolved"; return 1; }
            return 0
        fi
        warn "extraction from $resolved failed; see borg output above"; return 1
    fi
    say "extracting $resolved (archive: $archive) into $dest..."
    if borg extract ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --progress "::$archive"; then say "restored $resolved into $dest"; return 0; fi
    warn "extraction from $resolved failed; see borg output above"; return 1
}

# Restore one repo's folders to their original parent directories. BORG_REPO and
# the passphrase environment must already be set. Returns non-zero on failure.
_restore_folders_in_place() {
    local repo="$1" archive="$2" drive="$3"
    say "restoring $repo (archive: $archive) in place from $drive..."
    # An in-place restore covering ~/.borg-config or the passphrase file would
    # overwrite the running tool's own inputs. This run is unaffected (it holds
    # them in memory), but snapshot and warn so you re-check before the next.
    local cfg_before="" pass_before=""
    if [[ -e "$CONFIG_FILE" ]]; then cfg_before=$(sha256sum -- "$CONFIG_FILE" 2>/dev/null) || true; fi
    if [[ -n "$PASSPHRASE_PATH" && -e "$PASSPHRASE_PATH" ]]; then pass_before=$(sha256sum -- "$PASSPHRASE_PATH" 2>/dev/null) || true; fi
    if [[ -z "${REPO_SRC[$repo]:-}" ]]; then
        warn "$repo has no backup_data folders, so there is nothing to restore in place"
        return 1
    fi
    local rc=0 src parent base
    while IFS= read -r src; do
        [[ -n "$src" ]] || continue
        parent=$(dirname "$src"); base=$(basename "$src")
        if [[ -n "$SRC_HOME" && ( "$parent" == "$SRC_HOME" || "$parent" == "$SRC_HOME"/* ) ]]; then
            parent="$HOME${parent#"$SRC_HOME"}"
        elif [[ -n "$SRC_HOME" && "$src" == "$SRC_HOME" ]]; then
            # A source that is the foreign home itself has nothing left to remap:
            # the archive holds it under its own name, so it can only land beside
            # the real one. Say so rather than let the write fail unexplained.
            warn "SRC_HOME cannot remap '$src': the archive stores it as '$base', so it restores to '$parent/$base', not under your home"
        fi
        say "  $base -> $parent/"
        if ! ( mkdir -p "$parent" && cd "$parent" && borg extract ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --progress "::$archive" "$base" ); then
            warn "failed to restore '$base' into '$parent'"; rc=1
        fi
    done <<< "${REPO_SRC[$repo]}"
    local cfg_after="" pass_after=""
    if [[ -e "$CONFIG_FILE" ]]; then cfg_after=$(sha256sum -- "$CONFIG_FILE" 2>/dev/null) || true; fi
    if [[ -n "$PASSPHRASE_PATH" && -e "$PASSPHRASE_PATH" ]]; then pass_after=$(sha256sum -- "$PASSPHRASE_PATH" 2>/dev/null) || true; fi
    [[ "$cfg_before"  == "$cfg_after"  ]] || warn "this restore overwrote $CONFIG_FILE; this run still uses the config loaded at start, but re-check that file before your next run"
    [[ "$pass_before" == "$pass_after" ]] || warn "this restore overwrote $PASSPHRASE_PATH; this run still uses the passphrases loaded at start, but re-check it before your next run"
    (( rc == 0 )) && say "restored $repo in place (existing files overwritten, others left as-is)"
    return "$rc"
}

# The one gate every destructive extract path goes through. $1 is the -y flag and
# $2 states what is about to be overwritten, without trailing punctuation, so the
# same sentence serves the prompt and the refusal. -y proceeds, a terminal asks,
# and no terminal fails closed rather than prompting into a void.
_confirm() {
    local assume_yes="$1" what="$2" ans
    (( assume_yes )) && return 0
    [[ -t 0 ]] || die "$what; re-run with -y to confirm (no terminal to prompt)"
    read -rp "$what; continue? [y/N] " ans
    [[ "$ans" == [Yy] || "$ans" == [Yy][Ee][Ss] ]] || die "aborted"
}

# True if the path is a directory holding at least one entry.
_dir_nonempty() {
    [[ -d "$1" ]] || return 1
    local out
    out=$(ls -A -- "$1" 2>/dev/null) || true
    [[ -n "$out" ]]
}

# Every mounted drive holding this repo, in config order, one label per line.
# Empty output means no copy is reachable. Called with 'reasons' as $2 it prints
# why each unusable drive was dropped instead of the labels; both answers come
# from one loop so they cannot drift. Nothing is warned here, because extract
# needs one good copy: the reasons are asked for at the one moment they are the
# answer, when no copy is left. archive and check warn per drive; that is their job.
_extract_candidates() {
    local repo="$1" want="${2:-labels}" d probe parent blocked
    # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
    for d in ${REPO_DRIVES[$repo]}; do
        if ! mountpoint -q "$MOUNT_BASE/$d"; then
            if [[ "$want" == reasons ]]; then printf '%s\n' "$(_not_mounted "$d")"; fi
            continue
        fi
        # Before the directory test, not after it: an untraversable parent makes
        # the repo look absent, and "no repo there" would send you looking for a
        # missing copy when the copy is there and closed to you.
        parent=$(_repo_parent "$d")
        if [[ ! -r "$parent" || ! -x "$parent" ]]; then
            if [[ "$want" == reasons ]]; then printf '%s\n' "cannot look inside $parent; take the drive with '$CMD claim $d'"; fi
            continue
        fi
        probe=$(_repo_path "$d" "$repo")
        if [[ ! -d "$probe" ]]; then
            if [[ "$want" == reasons ]]; then printf '%s\n' "no $repo repo on $d (looked at $probe)"; fi
            continue
        fi
        # A copy borg cannot read is not a candidate. Calling borg on it buys a
        # Python traceback in place of a sentence, and the copy is unusable
        # either way, so it is dropped here with the remedy attached.
        blocked=$(_first_unreadable "$probe")
        if [[ -n "$blocked" ]]; then
            if [[ "$want" == reasons ]]; then printf '%s\n' "cannot read $blocked; take the drive with '$CMD claim $d'"; fi
            continue
        fi
        if [[ "$want" == labels ]]; then printf '%s\n' "$d"; fi
    done
    return 0
}

# Resolve the spec against one drive's copy and print the archive name.
# shellcheck disable=SC2030,SC2031  # per-drive borg env is subshelled by the caller's $( )
_archive_on_drive() {
    local repo="$1" drive="$2" spec="$3"
    local -x BORG_REPO; BORG_REPO=$(_repo_path "$drive" "$repo")
    _set_read_lock "$BORG_REPO" "$drive"
    local pc; pc=$(_repo_passcommand "$repo"); [[ -n "$pc" ]] && local -x BORG_PASSCOMMAND="$pc"
    _resolve_archive "$BORG_REPO" "$spec"
}

# One complete restore attempt from a single drive's copy, into $dest or, with
# in_place set, back to the source folders. Returns 1 rather than exiting so the
# caller can fall through to the next copy; the subshell is needed here because
# this cd's, and it also keeps the borg environment off the next drive.
# shellcheck disable=SC2030,SC2031  # per-drive borg env is subshelled on purpose
_extract_from_drive() {
    local repo="$1" drive="$2" archive="$3" in_place="$4" dest="$5"; shift 5
    local paths=("$@")
    (
        local -x BORG_REPO; BORG_REPO=$(_repo_path "$drive" "$repo")
        _set_read_lock "$BORG_REPO" "$drive"
        local pc; pc=$(_repo_passcommand "$repo"); [[ -n "$pc" ]] && local -x BORG_PASSCOMMAND="$pc"

        if (( in_place )); then
            _restore_folders_in_place "$repo" "$archive" "$drive" || exit 1
            exit 0
        fi

        mkdir -p "$dest" || { warn "cannot create $dest"; exit 1; }
        cd "$dest"       || { warn "cannot cd to $dest"; exit 1; }

        # restore_to is folder-level, so it applies to a whole-repo restore only.
        # With --path the caller has named archive paths directly and those land
        # under $dest, which is what the flag has always meant.
        if (( ${#paths[@]} == 0 )) && _repo_has_restore_to "$repo"; then
            say "extracting $repo from $drive (archive: $archive)..."
            _restore_folders_to_targets "$repo" "$archive" "$dest" || exit 1
            say "restored $repo"
            exit 0
        fi

        if (( ${#paths[@]} > 0 )); then
            say "extracting ${#paths[@]} path(s) from $repo on $drive (archive: $archive) into $dest..."
            borg extract ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --progress "::$archive" "${paths[@]}" || exit 1
            # The archive's own starting names are the answer to a miss, and this
            # repo's backup_data lines hold them, so print them rather than
            # sending the reader off to run list. An adopted repo has none.
            local hint rsrc roots=()
            while IFS= read -r rsrc; do
                [[ -n "$rsrc" ]] || continue
                roots+=("$(basename "$rsrc")")
            done <<< "${REPO_SRC[$repo]:-}"
            if (( ${#roots[@]} > 0 )); then
                hint="a path starts at one of this repo's own folder names, case included: $(_csv "${roots[@]}")"
            else
                hint="a path starts at a source folder's own name, case included; check it against '$CMD list $repo $drive'"
            fi
            # Every requested path missed. That is the same on every copy, since a
            # path is archive-relative, so exit 2 to tell the caller not to spend
            # the other drives on it; a partial hit is a real success and stays 0.
            _report_arrivals "$dest" "$hint" "${paths[@]}" || exit 2
            exit 0
        fi

        say "extracting $repo from $drive (archive: $archive) into $dest..."
        borg extract ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --progress "::$archive" || exit 1
        say "restored $repo into $dest"
        exit 0
    )
}

# Restore one repo's folders, each into the parent its restore_to names, and the
# rest into $dest in a single call. BORG_REPO and the passphrase environment must
# already be set, and the caller must not have cd'd anywhere that matters: this
# cd's per folder. Returns non-zero if any folder failed.
#
# Only reached when the repo has at least one restore_to line, so the plain bare
# `borg extract` is untouched for every other repo. A folder dropped from
# backup_data since the archive was written lands in $dest with the remainder.
_restore_folders_to_targets() {
    local repo="$1" archive="$2" dest="$3"
    local rc=0 src base target key
    local -a skip=()
    while IFS= read -r src; do
        [[ -n "$src" ]] || continue
        base=$(basename "$src")
        key="$repo"$'\n'"$src"
        target="${REPO_RESTORE_TO[$key]:-}"
        [[ -n "$target" ]] || continue
        say "  $base -> $target/"
        if ! ( mkdir -p "$target" && cd "$target" && borg extract ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} --progress "::$archive" "$base" ); then
            warn "failed to restore '$base' into '$target'"; rc=1
        fi
        skip+=( --exclude "sh:$base" --exclude "sh:$base/**" )
    done <<< "${REPO_SRC[$repo]}"
    # Everything without a restore_to, plus whatever else the archive holds, in
    # one call into $dest. The excludes above are what keep the folders already
    # placed from being written a second time here.
    say "  the rest -> $dest/"
    if ! ( mkdir -p "$dest" && cd "$dest" && borg extract ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} ${skip[@]+"${skip[@]}"} --progress "::$archive" ); then
        warn "failed to restore the remainder of $repo into $dest"; rc=1
    fi
    return "$rc"
}

# Order the reachable copies of a repo best-first, printing "<archive>TAB<drive>"
# lines. A copy whose -N or default spec will not resolve is dropped here rather
# than at extract time, so a stub or archive-less repo directory cannot win the
# pick; a literal archive name resolves trivially on every copy, so for that spec
# nothing is dropped and a copy lacking the archive is found at extract time.
#
# Ranking is on the archive name's first ten characters, the date, descending,
# with the config-order index breaking every tie. That ten-character slice is its
# own sort field rather than a character offset into the name: a key range that
# runs past a short field spills into the next one and inverts the tiebreak.
# Compare only the date, never the whole name: names come from borg's {now},
# evaluated once per borg create, and _archive_repo walks a repo's drives in
# config order, so within one run the drive written last always carries the later
# name, and a whole-name sort would rank write order rather than currency.
# Residual: two runs on one day landing on different drives tie on the date and
# go to config order, so the copy chosen can be hours older than the freshest.
_extract_plan() {
    local repo="$1" spec="$2"; shift 2
    local -a cand=("$@")
    local -a found=()
    local i=0 d a
    for d in "${cand[@]}"; do
        if a=$(_archive_on_drive "$repo" "$d" "$spec"); then
            found+=("$(printf '%.10s\t%03d\t%s\t%s' "$a" "$i" "$a" "$d")")
        else
            warn "$repo on $d: no usable archive there, skipping this copy"
        fi
        i=$(( i + 1 ))
    done
    (( ${#found[@]} > 0 )) || return 0

    printf '%s\n' "${found[@]}" | sort -t$'\t' -k1,1r -k2,2n | cut -f3,4
}

# Try each copy in the plan until one restores. Args: <repo> <in_place> <dest>
# <plan entry>... -- <path>..., a plan entry being "<archive>TAB<drive>".
# Returns 0 on success, 1 when every copy failed, 2 when none of the requested
# paths exist in the archive, which is the same on every copy.
_restore_from_plan() {
    local repo="$1" in_place="$2" dest="$3"; shift 3
    local -a plan=() paths=()
    local a seen=0
    for a in "$@"; do
        if   (( seen ));       then paths+=("$a")
        elif [[ "$a" == -- ]]; then seen=1
        else                        plan+=("$a"); fi
    done
    local i archive drive drc
    for (( i = 0; i < ${#plan[@]}; i++ )); do
        archive="${plan[i]%%$'\t'*}"; drive="${plan[i]##*$'\t'}"
        drc=0
        _extract_from_drive "$repo" "$drive" "$archive" "$in_place" "$dest" ${paths[@]+"${paths[@]}"} || drc=$?
        (( drc == 0 )) && return 0
        if (( drc == 2 )); then
            warn "none of the requested paths exist in $repo; nothing was restored, and the other copies hold the same paths"
            return 2
        fi
        warn "extraction of $repo from $drive failed; see borg output above"
        if (( in_place )); then
            warn "whatever borg wrote before the failure is already in place; the rest of $repo was not restored"
        else
            warn "$dest may now hold a partial $repo tree"
        fi
        if (( i + 1 < ${#plan[@]} )); then
            # Name the next copy's archive: it is resolved per drive, so it can be
            # a different point in time from the one that just failed.
            warn "falling through to ${plan[i+1]##*$'\t'} (archive: ${plan[i+1]%%$'\t'*}); it overwrites what the failed attempt left"
        fi
    done
    return 1
}

# Every repo with a mounted drive and a stored passphrase. -i restores in place,
# else into a per-repo subdirectory of RESTORE_PATH. `archive no` gates archiving,
# not restore, so archive-off repos are included. Args: <in_place> <assume_yes>;
# --path is rejected by the caller, so there are no paths to carry.
_extract_all_repos() {
    local in_place="$1" assume_yes="$2"; shift 2
    # No archive spec here, and that includes -1. Counting back needs a drive to
    # count on and no one drive holds every repo, so every -N but the newest is
    # unanswerable; -1 is the newest, which is what this form already does, so
    # accepting it would be a token that changes nothing. One rule, either way.
    (( $# == 0 )) \
        || die_usage "restoring every repo always uses the newest archive; name the repo to choose another: $CMD extract <repo> $1"
    need mountpoint
    parse_config
    _require_repos_valid
    (( in_place )) || _require_restore_path
    # Both branches ask: one omitted word is the whole difference between
    # restoring a repo and restoring every one of them. Asked above the
    # passphrase block, not below it, so nothing physical is asked of you for a
    # restore you have not agreed to yet. The two checks above are free and stay
    # first, so a confirmed run is not turned away a moment later.
    if (( in_place )); then
        _confirm "$assume_yes" "$IN_PLACE_WARNING"
    else
        _confirm "$assume_yes" "restoring every configured repo (${#REPOS[@]}) under $RESTORE_PATH"
    fi
    # An unreadable passphrase file would make every repo below report "no stored
    # passphrase" and end the run on a summary blaming drives and passphrases,
    # when the one cause is this file. Load the names up front and refuse.
    if [[ "$(_pass_source_kind)" != none ]]; then
        _load_pass_names
        (( _PASS_NAMES_UNREADABLE )) && die "refusing to restore: $PASSPHRASE_PATH is present but unreadable, so every repo would be skipped as if it had no passphrase; fix the cause in the warning above, or restore one repo at a time and let borg prompt"
    fi
    # The documented fallback (no passphrase file configured, borg asks) is
    # honoured here as everywhere else, keeping to one copy per repo so the count
    # of prompts is two per repo rather than one per drive plus one.
    local prompt_mode=0 pass_gap=""
    if _pass_prompt_mode; then
        prompt_mode=1
        _prompt_notice "no passphrase file configured" "each repo" twice
    elif [[ -z "$PASSPHRASE_PATH" ]]; then
        pass_gap="no passphrase file configured and no terminal to prompt"
    elif [[ ! -e "$PASSPHRASE_PATH" ]]; then
        pass_gap="no passphrase file at $PASSPHRASE_PATH"
    else
        pass_gap="no set_pass line in $PASSPHRASE_PATH"
    fi
    local rc=0 r rdest rfirst no_drive=0 prc w
    local -a restored=() skipped=() failed=() rcand=() rplan=() rwhy=()
    for r in "${REPOS[@]}"; do
        rcand=()
        mapfile -t rcand < <(_extract_candidates "$r")
        if (( ${#rcand[@]} == 0 )); then
            # Ask the same loop why, rather than calling every empty answer a
            # missing drive: a mounted drive without this repo on it, and one
            # whose copy cannot be read, are different problems with different
            # remedies, and the single-repo path already reports them properly.
            rwhy=()
            mapfile -t rwhy < <(_extract_candidates "$r" reasons)
            for w in ${rwhy[@]+"${rwhy[@]}"}; do
                case "$w" in *"is not mounted at"*) no_drive=1 ;; esac
            done
            skipped+=("$r ($(_csv "${rwhy[@]}"))")
            continue
        fi
        # Only in-place needs the folder list; a restore under RESTORE_PATH
        # unpacks whatever the archive holds and does not consult it.
        if (( in_place )) && [[ -z "${REPO_SRC[$r]:-}" ]]; then
            skipped+=("$r (no backup_data folders to put back)"); continue
        fi
        if ! (( prompt_mode )) && ! _repo_has_pass "$r"; then skipped+=("$r ($pass_gap)"); continue; fi
        rdest="$RESTORE_PATH/$r"
        if (( ! in_place && ! assume_yes )) && _dir_nonempty "$rdest"; then
            skipped+=("$r (target $rdest not empty; leftovers there would look like part of the restore, -y to accept)"); continue
        fi
        rplan=()
        if (( prompt_mode )); then
            # One borg list per drive is one prompt here, so keep to the first
            # reachable copy and accept that a stale one is not demoted.
            if rfirst=$(_archive_on_drive "$r" "${rcand[0]}" ""); then
                rplan=("$rfirst"$'\t'"${rcand[0]}")
            fi
        else
            mapfile -t rplan < <(_extract_plan "$r" "" "${rcand[@]}")
        fi
        if (( ${#rplan[@]} == 0 )); then skipped+=("$r (no usable archive on any copy; see the borg errors above)"); continue; fi
        prc=0
        _restore_from_plan "$r" "$in_place" "$rdest" "${rplan[@]}" -- || prc=$?
        if (( prc == 0 )); then restored+=("$r"); else rc=1; failed+=("$r"); fi
    done
    say ""
    local where="in place"; (( in_place )) || where="under $RESTORE_PATH"
    say "restored $where: ${restored[*]:-none}"
    (( ${#failed[@]} == 0 ))  || warn "failed: ${failed[*]} (see output above)"
    (( ${#skipped[@]} == 0 )) || warn "skipped: ${skipped[*]}"
    # Said once for the whole run rather than per repo: every repo shares one
    # MOUNT_BASE, so a config carried over from another machine misses every
    # drive at once, and the per-repo skip line has no room to say where it
    # looked. Only when a drive really was absent: saying it for a drive that is
    # mounted sends you to edit a setting that was never wrong.
    (( no_drive == 0 )) || warn "drives are looked for under $MOUNT_BASE; if they are mounted somewhere else, that is MOUNT_BASE in $CONFIG_FILE"
    if (( ${#restored[@]} == 0 && rc == 0 )); then
        die "nothing was restored; every repo was skipped for the reason listed against it above"
    fi
    exit "$rc"
}

# Path mode: the repo is named by path, borg prompts since nothing is stored, and
# the extract lands in the current directory. Args: <assume_yes> <args...> --
# <paths...>. This is the rescue route, so it never reads the config.
_extract_by_path() {
    local assume_yes="$1"; shift
    local -a args=() paths=()
    local a seen=0
    for a in "$@"; do
        if   (( seen ));       then paths+=("$a")
        elif [[ "$a" == -- ]]; then seen=1
        else                        args+=("$a"); fi
    done
    local repo="${args[0]}" spec=""
    if (( ${#args[@]} >= 2 )); then
        [[ "${args[1]}" == */* ]] && die_usage "path mode extracts one repo at a time; '${args[1]}' looks like a second path"
        spec="${args[1]}"
    fi
    (( ${#args[@]} <= 2 )) || die_usage "path mode takes one repo path and at most one archive spec; got ${#args[@]}"
    # With --path the collisions are known without asking borg; without it they
    # are not, and finding out would cost another listing and so another
    # passphrase prompt, so "is the directory empty" stands in for it.
    local rc=0 ppresent=() pp
    if (( ${#paths[@]} > 0 )); then
        for pp in "${paths[@]}"; do
            [[ -e "$PWD/$pp" ]] && ppresent+=("$pp")
        done
        (( ${#ppresent[@]} == 0 )) || _confirm "$assume_yes" "$PWD already contains: $(_csv "${ppresent[@]}")"
    elif _dir_nonempty "$PWD"; then
        _confirm "$assume_yes" "$PWD is not empty, and extracting here overwrites whatever the archive also holds"
    fi
    _borgex_path "$repo" "$spec" ${paths[@]+"${paths[@]}"} || rc=1
    exit "$rc"
}

# Label mode: a configured (or adopted) repo, restored into RESTORE_PATH
# or, with -i, back to its source folders. Args: <in_place> <assume_yes>
# <args...> -- <paths...>. The caller has already run parse_config.
_extract_by_label() {
    local in_place="$1" assume_yes="$2"; shift 2
    local -a args=() paths=()
    local a seen=0
    for a in "$@"; do
        if   (( seen ));       then paths+=("$a")
        elif [[ "$a" == -- ]]; then seen=1
        else                        args+=("$a"); fi
    done
    _require_repos_valid

    local repo="${args[0]}" spec="" drive_arg="" tok
    # Everything after the repo is classified by what it is, not by where it sits,
    # so 'extract docs -2 d1' and 'extract docs d1 -2' are the same command. Drive
    # labels are a closed set the config declares and archive names come from
    # borg's {now}, so the two cannot collide in practice; the drive test runs
    # first regardless, because it is a set lookup rather than a guess.
    for tok in ${args[@]+"${args[@]:1}"}; do
        if [[ "$tok" =~ ^-[0-9]+$ ]]; then
            [[ -z "$spec" ]] || die_usage "extract takes one archive spec; got '$spec' and '$tok'"
            spec="$tok"
        elif _repo_has_drive "$repo" "$tok"; then
            [[ -z "$drive_arg" ]] || die_usage "extract takes one drive; got '$drive_arg' and '$tok'"
            drive_arg="$tok"
        else
            case " $ALL_DRIVES " in
                *" $tok "*) _require_repo_drive "$repo" "$tok" ;;
            esac
            [[ -z "$spec" ]] || die_usage "extract takes one archive spec; got '$spec' and '$tok'"
            spec="$tok"
        fi
    done

    # Counting back is drive-relative: two copies hold different numbers of
    # archives whenever a drive was away for a run, so '-3' left to the ranker
    # would mean the third-newest on whichever copy it chose, which need not be
    # the one 'list <repo> <drive>' just showed. Counting back therefore names its
    # drive, and the restore is pinned to that copy with no fallthrough. -1 and a
    # literal archive name mean the same thing on every copy and need no drive.
    if [[ "$spec" =~ ^-([0-9]+)$ ]] && (( BASH_REMATCH[1] >= 2 )); then
        [[ -n "$drive_arg" ]] \
            || die_usage "'$spec' counts back from the newest, and each copy counts separately; name the drive, as in '$CMD extract $repo $spec <drive>' ($repo has: $(_repo_drives_csv "$repo"))"
    fi

    # Every mounted drive that has this repo. One copy failing does not end the
    # restore: the copies are ordered and tried in turn, unless a drive pinned one.
    local -a cand=()
    mapfile -t cand < <(_extract_candidates "$repo")
    if [[ -n "$drive_arg" ]]; then
        local c pinned=""
        for c in ${cand[@]+"${cand[@]}"}; do
            [[ "$c" == "$drive_arg" ]] && pinned="$c"
        done
        if [[ -z "$pinned" ]]; then
            mountpoint -q "$MOUNT_BASE/$drive_arg" \
                || die "$(_not_mounted "$drive_arg"); plug it in and re-run"
            die "no $repo repo on $drive_arg ($(_repo_path "$drive_arg" "$repo"))"
        fi
        cand=("$pinned")
    fi
    if (( ${#cand[@]} == 0 )); then
        warn "no mounted drive has the $repo repo"
        local why
        while IFS= read -r why; do warn "  $why"; done < <(_extract_candidates "$repo" reasons)
        exit 1
    fi

    # Ordering the copies costs one borg list per drive, and each list is one use
    # of the passphrase. Stored, that is silent; unstored, borg would prompt once
    # per drive before anything is extracted, so keep to the first copy and say so.
    local -a plan=()
    # shellcheck disable=SC2031  # reads the caller's environment, not the per-drive exports in the helpers
    if [[ -n "${BORG_PASSPHRASE:-}${BORG_PASSPHRASE_FD:-}${BORG_PASSCOMMAND:-}" ]] || _repo_has_pass "$repo"; then
        mapfile -t plan < <(_extract_plan "$repo" "$spec" "${cand[@]}")
        (( ${#plan[@]} > 0 )) || die "no copy of $repo holds a usable archive; run: $CMD check $repo"
    else
        # Two situations reach here and only one is "no entry for this repo": a
        # passphrase file that is present but could not be read leaves PASS_NAMES
        # empty for every repo, and calling that a missing entry sends you looking
        # for a set_pass line that is already there.
        local pass_why="no stored passphrase"
        (( _PASS_NAMES_UNREADABLE )) && pass_why="could not read $PASSPHRASE_PATH, so no passphrase is available"
        (( ${#cand[@]} == 1 )) \
            || warn "$pass_why for $repo; keeping to the first copy (${cand[0]}) rather than prompting once per drive"
        local how=once
        _spec_lists_first "$spec" && how=twice
        _prompt_notice "$pass_why" "$repo" "$how"
        local first_archive
        first_archive=$(_archive_on_drive "$repo" "${cand[0]}" "$spec") || exit 1
        plan=("$first_archive"$'\t'"${cand[0]}")
    fi

    local dest="$RESTORE_PATH"
    if (( in_place )); then
        (( ${#paths[@]} == 0 )) || die_usage "--in-place restores whole folders to their origins; drop --path"
        # Before the confirm, not after: there is nothing to overwrite, so asking
        # would be asking about an action that cannot happen.
        [[ -n "${REPO_SRC[$repo]:-}" ]] \
            || die "$repo has no backup_data folders, so there is nothing to put back; restore it under $RESTORE_PATH instead by dropping -i"
        _confirm "$assume_yes" "$IN_PLACE_WARNING"
    else
        _require_restore_path
        # A named repo restores flat into RESTORE_PATH, so its folders land
        # under their own names exactly as the archive holds them. That directory
        # is shared with whatever else lives there, so the only thing worth
        # gating on is a name this restore would overwrite. The whole-suite form
        # keeps its per-repo directory and its stricter non-empty gate, because
        # there the directory belongs to the restore and leftovers in it really
        # do read as part of it.
        local sp present=() b
        if (( ${#paths[@]} > 0 )); then
            for sp in "${paths[@]}"; do
                [[ -e "$dest/$sp" ]] && present+=("$sp")
            done
        else
            while IFS= read -r sp; do
                [[ -n "$sp" ]] || continue
                b=$(basename "$sp")
                [[ -e "$dest/$b" ]] && present+=("$b")
            done <<< "${REPO_SRC[$repo]:-}"
        fi
        if (( ${#present[@]} > 0 )); then
            _confirm "$assume_yes" "$dest already contains: $(_csv "${present[@]}"); these are overwritten, and anything else of that name left there stays"
        fi
    fi

    local rc=0
    _restore_from_plan "$repo" "$in_place" "$dest" "${plan[@]}" -- ${paths[@]+"${paths[@]}"} || rc=$?
    (( rc == 0 )) || warn "no copy of $repo could be restored"
    exit "$rc"
}

cmd_extract() {
    ensure_borg
    local paths=() args=() in_place=0 assume_yes=0
    while (( $# > 0 )); do
        case "$1" in
            -i|--in-place) in_place=1; shift ;;
            -y|--yes) assume_yes=1; shift ;;
            --path)
                (( $# >= 2 )) || die_usage "--path needs a value"
                [[ -n "$2" ]]  || die_usage "--path value must not be empty"
                paths+=("$2"); shift 2 ;;
            --path=*) die_usage "use '--path <value>'; '--path=value' is not supported" ;;
            -[0-9]*)                                         # -N archive spec (e.g. -3)
                [[ "$1" =~ ^-[0-9]+$ ]] || die_usage "unknown flag: $1"
                args+=("$1"); shift ;;
            -*) die_usage "unknown flag: $1" ;;
            *) args+=("$1"); shift ;;
        esac
    done

    # An in-place restore writes over the very folders an archive run reads, so
    # it takes the same lock those runs hold. A restore into RESTORE_PATH cannot
    # disturb a concurrent backup, so it is left unlocked.
    (( in_place )) && _acquire_lock

    # No repo named is the whole-suite restore. The test is on the first argument
    # rather than on the presence of a bare word anywhere, because everything is
    # classified relative to the repo and the repo comes first: '-1 docs' is a
    # mis-ordered command either way, and reading the repo out of the middle
    # would start accepting it.
    if (( ${#args[@]} == 0 )) || [[ "${args[0]}" =~ ^-[0-9]+$ ]]; then
        (( ${#paths[@]} == 0 )) || die_usage "with no repo named, extract restores whole repos; name the repo to use --path"
        _extract_all_repos "$in_place" "$assume_yes" ${args[@]+"${args[@]}"}
    fi

    # Routing. An argument containing a slash is a repo path and takes that route
    # before anything is read, so a restore from an explicit path still works
    # when the config is absent or malformed; that is the rescue property. A bare
    # word is decided after parsing, because _adopt_drive_repo needs the
    # MOUNT_BASE and ALL_DRIVES parse_config resolves. A declared repo beats a
    # same-named directory in the current folder.
    if [[ "${args[0]}" == */* ]]; then
        (( ! in_place )) || die_usage "--in-place needs a configured repo, not a repo path"
        _extract_by_path "$assume_yes" "${args[@]}" -- ${paths[@]+"${paths[@]}"}
    fi
    need mountpoint
    parse_config
    if _repo_declared "${args[0]}"; then
        :
    elif [[ -d "${args[0]}" ]]; then
        (( ! in_place )) || die_usage "--in-place needs a configured repo, not a repo path"
        _extract_by_path "$assume_yes" "${args[@]}" -- ${paths[@]+"${paths[@]}"}
    elif _adopt_drive_repo "${args[0]}"; then
        : # adopted: a repo directory of that name is on a mounted drive
    elif [[ " $ALL_DRIVES " == *" ${args[0]} "* ]]; then
        # Nothing else in this chain would name a drive as a drive: it is not a
        # repo, not a directory here, and not adoptable, so without this it
        # reports as a misspelt repo.
        die_usage "'${args[0]}' is a drive, not a repo; a drive says which copy to read, not what to restore (try: $CMD extract <repo> ${args[0]})"
    elif (( ${#REPOS[@]} == 0 )); then
        die "no repos are configured, and there is no directory at '${args[0]}' (relative to the current directory, $PWD); add repo_name blocks to $CONFIG_FILE, or give the repo's path"
    else
        die "'${args[0]}' is not a configured repo (check $CONFIG_FILE), and there is no directory of that name here either (relative to the current directory, $PWD)"
    fi
    _extract_by_label "$in_place" "$assume_yes" "${args[@]}" -- ${paths[@]+"${paths[@]}"}
}

## ─── list: one drive's copy, printed by borg ─────────────────────────
# borg is the thing that knows what is in a repo, so the wrapper's whole job here
# is to find the path, supply the passphrase and step aside. Everything after the
# drive label is handed to borg untouched, which is why --last, --first,
# --sort-by, --glob-archives, --format and --json all work without this script
# growing an opinion about any of them. Nothing is reformatted on the way out.
# local -x scopes the borg environment to this call, so no subshell is needed.
_list_one() {
    local repo="$1" dir="$2"; shift 2
    local -x BORG_REPO="$dir"
    _set_read_lock "$dir"
    local pc; pc=$(_repo_passcommand "$repo"); [[ -n "$pc" ]] && local -x BORG_PASSCOMMAND="$pc"
    borg list ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} "$@"
}

cmd_list() {
    (( $# >= 2 )) || die_usage "usage: $CMD list <repo> <drive> [borg list flags...]"
    # The repo and the drive come first, so a leading flag is an ordering mistake
    # rather than a repo called --last.
    [[ "$1" != -* ]] || die_usage "$CMD list takes the repo and the drive first: $CMD list <repo> <drive> [borg list flags...]"
    local repo="$1" drive="$2"; shift 2
    ensure_borg; need mountpoint
    parse_config
    _require_repos_valid

    _require_declared_repo "$repo"
    _require_repo_drive "$repo" "$drive"
    mountpoint -q "$MOUNT_BASE/$drive" || die "$(_not_mounted "$drive"); plug it in and re-run"
    local dir; dir=$(_repo_path "$drive" "$repo")
    [[ -d "$dir" ]] || die "no $repo repo on $drive ($dir)"

    # shellcheck disable=SC2031  # reads the caller's environment, not the per-drive exports in the helpers
    if ! _repo_has_pass "$repo" \
        && [[ -z "${BORG_PASSPHRASE:-}${BORG_PASSPHRASE_FD:-}${BORG_PASSCOMMAND:-}" ]]; then
        _prompt_notice "no stored passphrase" "$repo"
    fi

    # borg's own exit status, not one of this script's. It is borg's answer.
    local rc=0
    _list_one "$repo" "$dir" "$@" || rc=$?
    exit "$rc"
}

## ─── check: deep-verify a repo on every drive ────────────────────────
# Fresh function scope per call so BORG_REPO and BORG_PASSCOMMAND never leak
# between repos.
_check_one() {
    local repo="$1" dir="$2"
    local -x BORG_REPO="$dir"
    local pc; pc=$(_repo_passcommand "$repo"); [[ -n "$pc" ]] && local -x BORG_PASSCOMMAND="$pc"
    _set_read_lock "$dir"
    borg check ${RO_LOCKOPT[@]+"${RO_LOCKOPT[@]}"} "$dir"
}

cmd_check() {
    (( $# <= 2 )) || die_usage "usage: $CMD check [repo [drive]]"
    ensure_borg; need mountpoint
    parse_config
    _require_repos_valid
    # Load the names here, in the parent, for the reason _load_pass_names gives.
    # check is read-only and borg's own prompt is a legitimate fallback, so this
    # warns where archive and a whole-suite restore refuse.
    if [[ "$(_pass_source_kind)" != none ]]; then
        _load_pass_names
        if (( _PASS_NAMES_UNREADABLE )); then
            _prompt_notice "no passphrase is available from $PASSPHRASE_PATH" "every repo below"
        fi
    fi
    local -a todo=()
    local want_drive=""
    if (( $# >= 1 )); then
        _require_declared_repo "$1"
        todo=("$1")
    else
        todo=("${REPOS[@]}")
    fi
    # The drive is optional here and required by list, deliberately: losing the
    # all-drives view costs list a convenience, and would cost check the operation
    # itself, since deep-verifying every copy is the whole point of check.
    if (( $# == 2 )); then
        want_drive="$2"
        _require_repo_drive "${todo[0]}" "$want_drive"
    fi
    local repo drive dir rc=0 checked
    for repo in "${todo[@]}"; do
        checked=0
        # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
        for drive in ${REPO_DRIVES[$repo]}; do
            [[ -z "$want_drive" || "$drive" == "$want_drive" ]] || continue
            if ! mountpoint -q "$MOUNT_BASE/$drive"; then warn "$(_not_mounted "$drive")"; continue; fi
            dir=$(_repo_path "$drive" "$repo")
            if [[ ! -d "$dir" ]]; then warn "no $repo repo on $drive"; continue; fi
            say "checking $repo on $drive (reads the whole repo, may be slow)..."
            if _check_one "$repo" "$dir"; then say "$repo on $drive: ok"; checked=1
            else warn "$repo on $drive: FAILED check"; rc=1; checked=1; fi
        done
        (( checked )) || { warn "no mounted drive had the $repo repo to check"; rc=1; }
    done
    exit "$rc"
}

## ─── pass-change: rotate a repo's passphrase ─────────────────────────
_rotate_one() {
    local repo="$1" dir="$2"
    local -x BORG_REPO="$dir"
    local pc; pc=$(_repo_passcommand "$repo"); [[ -n "$pc" ]] && local -x BORG_PASSCOMMAND="$pc"
    borg key change-passphrase "$dir"
}

cmd_pass_change() {
    (( $# == 1 )) || die_usage "usage: $CMD pass-change <repo>"
    local repo="$1"
    ensure_borg; need mountpoint
    parse_config
    _require_repos_valid
    _require_declared_repo "$repo"
    [[ -t 0 ]] || die "pass-change prompts for the new passphrase; no terminal to prompt, so run it interactively"

    _acquire_lock

    local ready=() unmounted=() no_repo=() pairs=() drive dir
    # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
    for drive in ${REPO_DRIVES[$repo]}; do
        dir=$(_repo_path "$drive" "$repo")
        if ! mountpoint -q "$MOUNT_BASE/$drive"; then unmounted+=("$drive")
        elif [[ ! -d "$dir" ]]; then no_repo+=("$drive")
        else ready+=("$drive"); pairs+=("$drive"$'\t'"$dir"); fi
    done
    (( ${#unmounted[@]} == 0 )) || die "all drives for $repo must be mounted; missing: ${unmounted[*]}"
    (( ${#no_repo[@]} == 0 )) || warn "drives without a $repo repo (skipped): ${no_repo[*]}"
    (( ${#ready[@]} > 0 )) || die "no drives have a $repo repo; nothing to rotate"
    # borg key change-passphrase rewrites the repo's config and takes a lock, so
    # every copy must be writable before any of them is touched: a rotation that
    # lands on some drives and not others leaves the repo on two passphrases.
    _require_writable "rotating the passphrase for $repo" "${pairs[@]}"
    say "will rotate passphrase for $repo on: ${ready[*]}"

    _require_pass "$repo"

    local new_pass new_pass2
    read -rsp "new passphrase: " new_pass; printf '\n' >&2
    read -rsp "confirm new passphrase: " new_pass2; printf '\n' >&2
    [[ -n "$new_pass" ]] || die "passphrase must not be empty"
    [[ "$new_pass" == "$new_pass2" ]] || die "passphrases do not match"
    unset new_pass2

    local -x BORG_NEW_PASSPHRASE="$new_pass"
    local rotated=() failed=()
    for drive in "${ready[@]}"; do
        dir=$(_repo_path "$drive" "$repo")
        if _rotate_one "$repo" "$dir"; then say "rotated on $drive"; rotated+=("$drive")
        else warn "rotation failed on $drive"; failed+=("$drive"); fi
    done
    unset BORG_NEW_PASSPHRASE

    if (( ${#failed[@]} > 0 )); then
        warn "drives now on new passphrase: ${rotated[*]:-none}"
        warn "drives still on old passphrase: ${failed[*]}"
        warn "passphrase file NOT updated; fix the failed drives and re-run, or revert with:"
        warn "  BORG_PASSPHRASE='<new>' BORG_NEW_PASSPHRASE='<old>' borg key change-passphrase <repo-dir>"
        unset new_pass
        exit 1
    fi
    _update_pass_value "$repo" "$new_pass"
    unset new_pass
    say "done; $repo is on the new passphrase on ${#rotated[@]} drive(s), passphrase file updated"
}

## ─── rename: rename a repo across drives ─────────────────────────────
cmd_rename() {
    (( $# == 2 )) || die_usage "usage: $CMD rename <old-repo> <new-name>"
    local old="$1" new="$2"
    [[ "$old" != "$new" ]] || die_usage "old and new names are the same: $old"
    _validate_segment "new repo name" "$new"
    _reject_reserved_name "$new"
    need mountpoint
    parse_config
    _require_repos_valid
    _require_declared_repo "$old"
    ! _repo_declared "$new" || die "$new is already a configured repo; pick a different new name"

    _acquire_lock

    local ready=() unmounted=() no_repo=() collide=() pairs=() drive od nd parent
    # shellcheck disable=SC2086  # space-joined validated drive tokens; intentional split
    for drive in ${REPO_DRIVES[$old]}; do
        od=$(_repo_path "$drive" "$old"); nd=$(_repo_path "$drive" "$new")
        if ! mountpoint -q "$MOUNT_BASE/$drive"; then unmounted+=("$drive")
        elif [[ ! -d "$od" ]]; then no_repo+=("$drive")
        elif [[ -e "$nd" ]]; then collide+=("$drive")
        else ready+=("$drive"); pairs+=("$drive"$'\t'"$(_repo_parent "$drive")"); fi
    done
    (( ${#unmounted[@]} == 0 )) || die "all drives for $old must be mounted; missing: ${unmounted[*]}"
    (( ${#collide[@]} == 0 )) || die "new name $new already exists on: ${collide[*]}; pick another"
    (( ${#no_repo[@]} == 0 )) || warn "drives without a $old repo (skipped): ${no_repo[*]}"
    # A rename needs write permission on the directory holding the repo, not on
    # the repo itself, and it needs it on every drive before the first mv: a
    # rename that succeeds on d1 and is refused on d2 leaves the two drives on
    # different names with the config matching neither, to be repaired by hand.
    if (( ${#ready[@]} > 0 )); then
        _require_writable "renaming $old to $new" "${pairs[@]}"
        say "will rename $old -> $new on: ${ready[*]}"
    else
        say "no on-disk archives for $old; updating config and passphrase only"
    fi

    local renamed=() failed=()
    for drive in "${ready[@]}"; do
        od=$(_repo_path "$drive" "$old"); nd=$(_repo_path "$drive" "$new")
        if mv -T "$od" "$nd"; then say "renamed on $drive"; renamed+=("$drive")
        else warn "rename failed on $drive"; failed+=("$drive"); fi
    done
    if (( ${#failed[@]} > 0 )); then
        parent=$(_repo_parent "<drive>")
        warn "drives renamed: ${renamed[*]:-none}"
        warn "drives still on old name: ${failed[*]}"
        warn "config and passphrase file NOT updated; rename those back with:"
        warn "  mv $parent/$new $parent/$old"
        exit 1
    fi
    # The config first, then the passphrase label: _update_repo_name dies when its
    # line is missing while _update_pass_label only warns, so doing the fatal one
    # first means a failure leaves both files untouched rather than the passphrase
    # file already relabelled against a config that still says the old name.
    _update_repo_name "$old" "$new"
    _update_pass_label "$old" "$new"
    say "done; renamed on ${#renamed[@]} drive(s); config and passphrase file updated"
}

## ─── help and dispatch ───────────────────────────────────────────────
# The identity line is line 2 of this file, so the filename, the header comment
# and `version` output cannot drift from each other the way a separate VERSION
# constant would. Read from $SELF, the resolved path to this script.
_identity() {
    local line=""
    [[ -f "$SELF" ]] && line=$(sed -n '2p' -- "$SELF")
    line="${line#\#}"
    line="${line# }"
    [[ -n "$line" ]] || line="borg-simple, version unknown (could not read line 2 of $SELF)"
    printf '%s\n' "$line"
}

cmd_version() { _identity; }

usage() {
    # Unquoted heredoc so the command word is the name you invoked, not one baked
    # in here. Only two values expand: $CMD and $cfg. Every line in the commands
    # block opens with the same "  $CMD " prefix so the description column stays
    # aligned whatever the name is. $cfg is the config path with the home prefix
    # folded back to a tilde, which stops it drifting from CONFIG_FILE.
    local cfg="$CONFIG_FILE"
    [[ "$cfg" == "$HOME"/* ]] && cfg="~${cfg#"$HOME"}"
    cat <<EOF
$CMD: encrypted, versioned borg backups to USB drives.
Each repo is written to every one of its drives, so any single drive on its
own can restore it. What to back up, and to which drives, is set in the
config file, $cfg.

commands
  $CMD                      back up every repo
  $CMD <repo>               back up that one repo
  $CMD init [...]           set up a repo, or create configured ones
  $CMD extract [repo] [...] restore folders from a repo, or all
  $CMD list <repo> <drive>  show one copy's archives
  $CMD check [repo [drive]] verify copies against their hashes
  $CMD claim <drive>        take ownership of a drive's repo directories
  $CMD pass-change <repo>   change a repo's passphrase everywhere
  $CMD rename <old> <new>   rename a repo everywhere
  $CMD version              name and version of this script  (--version)
  $CMD help                 print this text                  (--help, -h)

init is the only command that writes to the config file.
    $CMD init <repo> <path>...  write the repo's block, store a passphrase,
                                then create it on each of its drives
    $CMD init <repo>            create an already-configured repo
    $CMD init                   the same, for every configured repo
    --drives <label>...         which drives a new repo lives on, default
                                every drive in ALL_DRIVES; every word after
                                it is read as a drive label, so put it last
    Creating a repo needs all of its drives mounted and writable, so every
    copy starts together; completing an existing repo does not.

extract puts a repo's folders under RESTORE_PATH, or back where they
came from with -i. Everything after the repo comes in any order. With no repo
named it restores every repo that has a mounted drive and a passphrase, whole,
and asks first; that form always uses the newest archive.
    -N                     which archive: -1 newest, -2 the one before it,
                           default -1
    <drive>                which copy to read; needed for -2 and lower,
                           since each drive counts back separately
    <archive>              an archive name from list, instead of -N
    --path <path>          restore only that path, a folder or a file;
                           repeat for more
    -i, --in-place         put folders back where they came from
    -y, --yes              overwrite without asking first
    <repo-path>            a repo directory instead of a repo name: no
                           config needed, borg prompts, files land here

claim is for a drive whose files belong to another user, which borg cannot
write to. It takes the drive's mountpoint and its repo directories, never
the whole drive, and shows what it will take before asking. It needs a
terminal, so it never runs under cron.

list hands borg everything after the drive, so --last 20, --sort-by,
--json and the rest work exactly as borg documents them.

examples
    $CMD init docs ~/Documents ~/Notes
        set up a repo named docs holding those two folders
    $CMD
        back up every repo now
    $CMD list docs d1
        what the docs copy on d1 holds
    $CMD extract docs -2 d1 --path Notes
        restore just Notes into RESTORE_PATH, taking the archive one
        back from newest on d1; count on the same drive you listed
    $CMD extract docs -i
        put every folder of docs back where it came from
    $CMD claim d2
        take ownership of d2's repo directories after a permission error

config, $cfg
    Per repo: repo_name, backup_data, backup_drives, keep, exclude,
    include_only, include_only_in, restore_to, compression, archive.
    Above the blocks: MOUNT_BASE, RESTORE_PATH, REPO_SUBDIR,
    PASSPHRASE_PATH, ALL_DRIVES, SRC_HOME.
    To remove a repo, delete its block; its archives and its set_pass
    line stay. The guide has the format and cron use.
EOF
}

# Hidden self-test, run before treating a version final (alongside shellcheck).
# The subcommand list, the dispatch case and the help banner are three hand-kept
# things, so assert every SUBCOMMANDS entry has a cmd_ handler, a branch in the
# dispatch case, and a line in the banner's commands block.
_selfcheck() {
    local w fn rc=0 help_text dispatch
    help_text=$(usage)
    dispatch=$(declare -f main)
    for w in "${SUBCOMMANDS[@]}"; do
        fn="cmd_${w//-/_}"
        declare -F "$fn" >/dev/null || { warn "no handler $fn for subcommand '$w'"; rc=1; }
        # Anchored to a case pattern, so the word occurring in a message or in
        # another function's name cannot stand in for a missing branch.
        grep -qE "(^|[[:space:]|])${w}[[:space:]]*[|)]" <<<"$dispatch" \
            || { warn "subcommand '$w' has no branch in main's dispatch case"; rc=1; }
        # Anchored to a commands-block line, not to the word anywhere in the text:
        # 'list' and 'version' both occur in the banner's prose, so a loose match
        # would pass with the command's own line deleted.
        grep -q "^  $CMD $w\b" <<<"$help_text" || { warn "subcommand '$w' missing from the help banner's commands block"; rc=1; }
    done
    (( rc == 0 )) && say "selfcheck ok (${#SUBCOMMANDS[@]} subcommands)"
    return "$rc"
}

main() {
    [[ $- == *x* ]] && die "refusing to run under bash -x: it would trace passphrases; debug with a targeted set -x around a non-secret section"
    # Personal tool: bare invocation runs the archive directly, no confirm, under
    # the spec's CLI carve-out; an unknown bare token is treated as a repo name.
    # An argument that is the empty string (an unset "$REPO" in a cron line) is a
    # mistake, not a full run, so it is rejected below rather than escalated.
    # cmd_archive exits on every path rather than returning, so the exit here is
    # a net for the day that stops being true, not a live line.
    # shellcheck disable=SC2317  # deliberately unreachable while cmd_archive always exits
    (( $# == 0 )) && { cmd_archive; exit; }
    case "${1:-}" in
        _emit-pass)  shift; _emit_pass "$@" ;;   # hidden: borg's passcommand re-invokes this to print one repo's passphrase
        _pass-names) shift; _pass_names "$@" ;;  # hidden: prints repo names that have a stored passphrase
        _selfcheck)  _selfcheck ;;               # hidden: assert the dispatch table is internally consistent
        -h|--help|help) usage ;;
        version|--version) cmd_version ;;
        init)        shift; cmd_init "$@" ;;
        extract)     shift; cmd_extract "$@" ;;
        list)        shift; cmd_list "$@" ;;
        check)       shift; cmd_check "$@" ;;
        claim)       shift; cmd_claim "$@" ;;
        pass-change) shift; cmd_pass_change "$@" ;;
        rename)      shift; cmd_rename "$@" ;;
        "")          die_usage "empty repo name; run '$CMD' with no arguments to back up every repo, or name a repo" ;;
        -*)          die_usage "unknown option: $1 (try: $CMD --help)" ;;
        *)           cmd_archive "$@" ;;
    esac
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

Borg-simple core

This script is a smaller, bare-case version of the Borg-simple script, kept short so experts can review the core functions quickly. It reads the same ~/.borg-config and the same passphrase file, plain or GPG, and it has exactly two commands: backup, which archives every repo in the config to its drives, and extract <repo> <drive>, which unpacks that repo’s newest archive into the directory you are standing in. There is no retention, no in-place restore, no init command (though backup creates a repo that isn’t on a mounted drive yet), and no allowlist handling; a repo with an allowlist is skipped rather than guessed at. Hover over the code block and press the copy button to take all of it. (The script’s own header says borg-super-simple, its original name.)


#!/bin/bash
# borg-super-simple, v10

set -euo pipefail
case $- in *x*) printf 'refusing to run under set -x\n' >&2; exit 1 ;; esac

CONFIG_FILE="$HOME/.borg-config"
MOUNT_BASE="/media/$(id -un)"
REPO_SUBDIR=""
PASSPHRASE_PATH=""
ALL_DRIVES=""
SELF=$(readlink -f -- "$0")

declare -A SRC=() DRIVES=() EXCL=() ALLOW=() ARCH=() PASS=()
declare -a REPOS=()
CUR=""

say()  { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
die()  { printf '%s\n' "$*" >&2; exit 1; }

# shellcheck disable=SC2088  # '~/' is a literal pattern here, expanded by hand
_abs() { case "$1" in /) printf '/\n' ;; /*) printf '%s\n' "${1%/}" ;;
                      '~') printf '%s\n' "$HOME" ;;
                      '~/'*) printf '%s\n' "$HOME/${1#'~/'}" ;;
                      *) printf '%s\n' "$HOME/${1%/}" ;; esac; }

repo_name()       { CUR="$1"; REPOS+=("$1"); ARCH["$1"]=1; }
backup_data()     { local p; for p in "$@"; do SRC[$CUR]+="$(_abs "$p")"$'\n'; done; }
backup_drives()   { if [[ "${1:-}" == all ]]; then DRIVES[$CUR]="$ALL_DRIVES"; else DRIVES[$CUR]="$*"; fi; }
exclude()         { local g; for g in "$@"; do EXCL[$CUR]+="$g"$'\n'; done; }
include_only()    { ALLOW[$CUR]=1; }
include_only_in() { ALLOW[$CUR]=1; }
archive()         { [[ "${1:-}" == no ]] && ARCH[$CUR]=0; return 0; }
set_pass()        { PASS["$1"]="$2"; }
keep() { :; }; restore_to() { :; }; compression() { :; }

require_private() {
    local mode
    [[ -r "$1" ]] || die "$1 not found or not readable"
    mode=$(stat -c '%a' "$1") || die "cannot stat $1"
    (( (8#$mode & 8#77) == 0 )) || die "$1 is reachable by group or other (mode $mode); run: chmod 600 $1"
}

# The version gate the full tool has, for the same reason and with more at stake
# here: this is the tool you reach for on the machine where things have gone
# wrong, so it must not be the one that quietly writes archives of a shape the
# full tool cannot restore. Below 1.4 the /./ anchor is ignored without a word,
# and every folder is stored under its full path instead of its own name, so
# `backup extract -i` later asks borg for 'Documents' and the archive holds
# 'home/john/Documents'. 2.x changed the archive layout and the command surface.
_require_borg() {
    command -v borg >/dev/null 2>&1 || die "borg not found; install it first"
    local out ver major minor
    out=$(borg --version 2>/dev/null) || die "borg is installed but 'borg --version' failed"
    ver="${out##* }"
    major="${ver%%.*}"; minor="${ver#*.}"; minor="${minor%%.*}"
    case "$major$minor" in
        ''|*[!0-9]*) die "cannot read a borg version out of '$out'; expected something like 'borg 1.4.0'" ;;
    esac
    if (( major >= 2 )); then
        die "borg $ver is 2.x, which changed the archive layout; install borg 1.4 or newer and below 2.0"
    fi
    if (( major < 1 || minor < 4 )); then
        die "borg $ver is too old: 1.4 is the first version that stores a folder at the top of the archive under its own name, and older borg ignores that silently, so these archives would hold full paths and 'backup extract -i' would not find the folders. Install borg 1.4 or newer (Debian trixie and Devuan Excalibur ship it; bookworm needs bookworm-backports)"
    fi
    return 0
}

load_config() {
    _require_borg
    require_private "$CONFIG_FILE"
    # An unimplemented directive must not abort the source, but a mistyped one
    # must not vanish either: it would silently drop a folder from the backup.
    # shellcheck disable=SC2317  # bash calls this indirectly while sourcing
    command_not_found_handle() { warn "ignoring unknown config directive '$1'"; return 0; }
    # shellcheck source=/dev/null
    source "$CONFIG_FILE" || die "failed to load $CONFIG_FILE"
    unset -f command_not_found_handle
    MOUNT_BASE=$(_abs "$MOUNT_BASE")
    [[ -d "$MOUNT_BASE" ]] || die "MOUNT_BASE '$MOUNT_BASE' is not a directory"
    [[ -z "$PASSPHRASE_PATH" ]] || PASSPHRASE_PATH=$(_abs "$PASSPHRASE_PATH")
}

# borg re-invokes _emit-pass through this, so the cleartext goes from gpg or cat
# straight into borg and never enters this process. Empty when there is no
# passphrase file, and borg then prompts.
_passcommand() {
    [[ -n "$PASSPHRASE_PATH" && -e "$PASSPHRASE_PATH" ]] || return 0
    case "$BASH$SELF$PASSPHRASE_PATH" in
        *[[:space:]\'\"\\]*) die "borg splits the passcommand without a shell, so these paths must have no whitespace, quotes or backslashes: '$BASH' '$SELF' '$PASSPHRASE_PATH'" ;;
    esac
    printf '%s %s _emit-pass %s %s\n' "$BASH" "$SELF" "$PASSPHRASE_PATH" "$1"
}

# Plain or GPG is decided by content, not by filename, so the same file the full
# tool reads works here whatever it is called.
_emit_pass() {
    (( $# == 2 )) || { printf '_emit-pass needs <file> <repo>\n' >&2; exit 2; }
    local ct
    require_private "$1"
    if LC_ALL=C grep -qaE '^[[:space:]]*set_pass[[:space:]]' -- "$1"; then
        ct=$(cat -- "$1") || exit 1
    elif LC_ALL=C grep -qa '[^[:print:][:space:]]' -- "$1" \
      || LC_ALL=C grep -qa '^-----BEGIN PGP MESSAGE-----' -- "$1"; then
        ct=$(gpg --quiet --decrypt "$1") || exit 1
    else
        ct=$(cat -- "$1") || exit 1
    fi
    eval "$ct"; unset ct
    [[ -n "${PASS[$2]:-}" ]] || { printf 'no passphrase for %s in %s\n' "$2" "$1" >&2; exit 1; }
    printf '%s' "${PASS[$2]}"
}

do_backup() {
    (( $# == 0 )) || die "usage: borg-super-simple backup"
    local repo drive src g root pc rc=0 crc
    local -a srcs pats roots
    for repo in ${REPOS[@]+"${REPOS[@]}"}; do
        (( ${ARCH[$repo]} )) && [[ -n "${SRC[$repo]:-}" ]] || continue
        [[ -z "${ALLOW[$repo]:-}" ]] || { warn "$repo uses include_only; this tool cannot apply allowlists, so it is skipped (use the full tool)"; rc=1; continue; }
        srcs=(); roots=(); pats=()
        while IFS= read -r src; do
            [[ -n "$src" ]] || continue
            srcs+=( "$(dirname "$src")/./$(basename "$src")" )
            roots+=( "${src#/}" )
        done <<< "${SRC[$repo]}"
        # borg matches patterns against the source path with its leading slash
        # off, never against the name the item is stored under, so anchor every
        # glob on each source root. '**/' also matches zero directories.
        while IFS= read -r g; do
            [[ -n "$g" ]] || continue
            if [[ "$g" == /* ]]; then pats+=( --pattern "- sh:${g#/}" )
            else for root in "${roots[@]}"; do pats+=( --pattern "- sh:$root/**/$g" ); done; fi
        done <<< "${EXCL[$repo]:-}"
        pc=$(_passcommand "$repo")
        for drive in ${DRIVES[$repo]:-$ALL_DRIVES}; do
            mountpoint -q "$MOUNT_BASE/$drive" || { warn "$drive not mounted; skipping $repo there"; rc=1; continue; }
            say "archiving $repo to $drive..."
            crc=0
            (
                export BORG_REPO="$MOUNT_BASE/$drive${REPO_SUBDIR:+/$REPO_SUBDIR}/$repo"
                [[ -z "$pc" ]] || export BORG_PASSCOMMAND="$pc"
                [[ -d "$BORG_REPO" ]] || borg init --encryption=repokey || exit 1
                borg create ${pats[@]+"${pats[@]}"} "::{now}" "${srcs[@]}"
            ) || crc=$?
            (( crc <= 1 )) || { warn "$repo on $drive failed (borg exited $crc)"; rc=1; }
        done
    done
    return "$rc"
}

do_extract() {
    (( $# == 2 )) || die "usage: borg-super-simple extract <repo> <drive>"
    local path latest pc
    local -a lock=()
    path="$MOUNT_BASE/$2${REPO_SUBDIR:+/$REPO_SUBDIR}/$1"
    [[ -d "$path" ]] || die "no repo at $path"
    [[ -w "$path" ]] || { lock=(--bypass-lock); warn "$path is not writable; reading with borg's lock bypassed"; }
    pc=$(_passcommand "$1")
    export BORG_REPO="$path"
    [[ -z "$pc" ]] || export BORG_PASSCOMMAND="$pc"
    latest=$(borg list ${lock[@]+"${lock[@]}"} --last 1 --short) || die "could not list $path"
    [[ -n "$latest" ]] || die "no archives in $path"
    say "extracting $1 from $2 (archive: $latest) into $PWD..."
    borg extract ${lock[@]+"${lock[@]}"} "::$latest"
}

case "${1:-}" in
    _emit-pass) shift; _emit_pass "$@" ;;
    backup)     shift; load_config; do_backup "$@" ;;
    extract)    shift; load_config; do_extract "$@" ;;
    *)          die "usage: borg-super-simple backup | borg-super-simple extract <repo> <drive>" ;;
esac

Manual Borg and GPG

  • For: people who would rather not run a script they did not write.
  • Before you start: Getting started with Borg: install, create a repository, back up, restore.
  • Reading time: about 11 minutes.
  • You end with: the borg and gpg commands for several drives, filtering, retention, verification and restoring, with no script in between.

Part 1 and Part 2 hand your data to a shell script somebody else wrote. This page explains the same job without the script, for those who prefer to minimise trust.

It is not a hand-written copy of the script. Most of what the script does is convenience you will not miss with two pendrives and a bit of patience. What you will miss is the handful of things borg does not do unless you ask. Those are what this page is about.

It targets borg 1.2 through 1.4, which is what Debian-family systems ship today. Check yours with borg --version.

What this page assumes

Getting started with Borg covers installing borg, creating a repository, making an archive, listing what is in one, and extracting it again. Read that first.

Once you can do those four things, you can back up. What you cannot yet do is trust the result, keep it from filling the drive, or run it without typing a passphrase over and over.

Several drives are several repositories

This is the idea everything else on this page depends on, and it is the one people get wrong.

Two pendrives holding a repo of the same name are not a mirrored pair. They are two independent repositories that happen to share a name, each with its own archives, its own key, and its own passphrase. Nothing keeps them in step except you running the same commands against both.

Three consequences, all of which bite eventually.

Changing a passphrase changes it on one repository. Do the second drive in the same sitting or you will have two passphrases for one name and no record of which drive holds which.

Renaming is mv on each drive, one at a time.

And a drive that was not plugged in last month is a month behind, silently, because nothing anywhere is tracking that. The only way to know is to look.

This is also why repokey is the right encryption mode for drives you carry. It keeps the key inside the repository, protected by your passphrase, so the drive is openable on any machine with nothing but the passphrase in your head. keyfile keeps the key in ~/.config/borg/keys instead, which means a stolen drive is useless without a file from your laptop, and a dead laptop leaves you with an unopenable drive.

Passphrases

Three rungs. Start on the first, move up when the typing annoys you more than the risk does.

Rung one, type it

Borg asks, you type, nothing is stored anywhere. This is the most secure arrangement there is and for two pendrives it is entirely workable.

One thing to know before you decide it is unworkable, and one before you decide it is fine.

It is not one prompt per backup. Borg asks once per operation that needs the repository key, and a careful run of one repo is three of those: the archive, the prune, and the check. Two drives makes six. That is the number people find out about on their second evening, not their first.

And every one of those prompts is a moment you cannot walk away from, which rules out running anything on a timer.

Rung two, a file borg reads for you

Put the passphrase in a file, and tell borg how to fetch it. Create the file from the keyboard: run the first line below, type the passphrase, press Enter, then press Ctrl-D. That keeps it out of your shell history, where a printf '...' > file would have left it.

(umask 077; cat > ~/.borg-pass-documents)
export BORG_PASSCOMMAND='cat /home/john/.borg-pass-documents'

Borg runs that command whenever it needs the passphrase and reads the answer from its output. The secret exists only inside that short-lived cat, never in borg’s environment.

The umask 077 matters as much as the rest. The file’s permissions are the only thing protecting it, so it is chmod 600 and stays that way.

What this buys: no typing, and a run you can leave alone. What it costs: anyone with root on the machine can read the passphrase, and so can anyone holding an unencrypted backup of your home directory.

This is the rung that works from cron, and it is the only one that does.

Rung three, a gpg-encrypted file

gpg --encrypt --recipient you@example.com --output ~/.borg-pass-documents.gpg ~/.borg-pass-documents
chmod 600 ~/.borg-pass-documents.gpg
gpg --decrypt ~/.borg-pass-documents.gpg

That last line is not optional. Check the file decrypts to what you expect before you delete the plaintext one.

Then:

export BORG_PASSCOMMAND='gpg --quiet --decrypt /home/john/.borg-pass-documents.gpg'

Be clear about what this does and does not buy you.

It buys protection at rest. The passphrase is no longer readable text on your disk, so a stolen drive of your home directory, or a stray copy of an old backup, no longer hands it over.

It does not buy unattended running. Borg re-runs that command for every operation that needs the key, and each run needs gpg able to decrypt right then. That means the agent primed, or you answering a prompt, or a hardware token touched, per operation. On a machine you are sitting at, the agent’s cache hides all of it. On a timer at four in the morning it fails, or worse, hangs.

So rung three is for the machine you use, and rung two is for the machine that backs itself up while you sleep. They are not an upgrade path; they are two different jobs. Choosing rung three and then wondering why cron stopped working is the single most common way to end up with a month of backups that did not happen.

The one to avoid

BORG_PASSPHRASE puts the passphrase directly in the environment.

Written inline, as BORG_PASSPHRASE=hunter2 borg create ..., it can show up in the process list where every user on the machine can read it. Borg’s own FAQ warns about exactly this. Written as an export, it sits in your shell history and in the environment of every process you start for the rest of the session.

There is a fourth option, BORG_PASSPHRASE_FD, which has borg read from a file descriptor you open and is the safest of all of them, at the cost of being awkward to type. Worth knowing it exists. Note also that BORG_PASSPHRASE overrides BORG_PASSCOMMAND, which overrides BORG_PASSPHRASE_FD, so a forgotten export from an experiment will quietly win over the arrangement you think you are using.

Three things about the passcommand

Borg runs it without a shell, so it expands neither ~ nor $HOME; write the path out in full. ($HOME works only inside double quotes, where your shell fills it in before borg sees it.)

It is split the way a shell splits a command line, so a quoted path with a space in it does survive. What does not survive is anything that needs a shell to mean anything: no pipes, no redirection, no command substitution. Put those inside sh -c 'the whole thing' and make that the passcommand.

And by hand, one small file per repository is easier than one file holding them all. The script keeps a combined file because it has parsing code; you do not, and cat or gpg -d on a single-line file needs none.

Filtering: order is the whole thing

Borg evaluates --pattern arguments in the order you give them, first match wins, and anything unmatched is kept.

That one rule is the only part worth memorising, because it decides something that matters. Put your drops before your keeps. An exclude listed first cannot be undone by an allowlist after it, which is what you want for anything you are deliberately keeping out of a backup. The other way round, an allowlist can pull back in the very thing you were excluding.

Check a pattern set before you trust it:

borg create --list --dry-run "::test" /home/john/Documents --pattern '- sh:**/cache/**'

An allowlist that matches nothing produces a perfectly valid, completely empty archive, and no error. --dry-run is how you find that out on the day you write it rather than the day you need it.

Retention: prune, then compact

Left alone, a repository grows until the drive is full. Pruning deletes archives by rule:

borg prune --keep-daily 7 --keep-weekly 4 --keep-monthly -1

A negative number means no limit, so -1 keeps that tier forever. Rules are applied shortest interval first, and an archive kept by one rule does not count towards the next. Since borg 1.2 the oldest archive is kept whenever a rule could not otherwise meet its target, so your first archive keeps ageing until a newer one qualifies to replace it.

There is no undo, so look before you leap:

borg prune -v --list --dry-run --keep-daily 7 --keep-weekly 4 --keep-monthly -1

Then the step that is easy to miss and makes the whole exercise pointless if you do:

borg compact

prune marks archives as deleted. compact is what rewrites the repository and hands the space back to the filesystem. Prune without compact frees nothing at all, and the drive fills anyway while you are looking at a shorter archive list and assuming otherwise.

Two smaller things.

Prune also clears out checkpoint archives, the partial ones borg leaves behind when a run is interrupted, so if you never prune those accumulate.

And when you run borg create --stats, the number worth reading is the deduplicated size. That is what this archive actually cost you on the drive. The original and compressed sizes describe the files, not the growth.

Verification: two kinds of check

Prune is about the drive filling up. Check is about the drive lying to you, which is the failure that matters and the one nothing announces.

Fast, straight after writing an archive:

borg check --archives-only --last 1

That reads the newest archive’s metadata and takes seconds. It catches the write that did not land, which is the common case: a drive pulled early, a cable knocked, a filesystem that went read-only halfway through. Run it every time. It costs nothing.

Slow, occasionally:

borg check /media/john/d1/documents

That reads the whole repository and verifies every chunk. On a large repo over USB it can take hours, so this is a thing you start and walk away from. It is what finds bit rot on an ageing pendrive, and pendrives do rot.

Run the slow one before you rely on a drive you have not touched in a year, and after any incident that involved the drive being unplugged mid-write. A backup you have never verified is a belief.

Restoring: mount, not just extract

Getting started covers borg extract, and extract is the right tool for putting a whole folder back.

It is the wrong tool for the far more common case, which is that you want one file and you are not sure which archive has the version you mean. For that, mount the archive:

mkdir -p ~/mnt
borg mount /media/john/d1/documents::2026-07-28T20:30:00 ~/mnt

It appears as a read-only filesystem. Open it in a file manager, look at the file, copy out the one you want, then:

borg umount ~/mnt

Leave off the ::archive and mount the whole repository instead, and every archive appears as its own directory. That is the quickest way to answer “which version of this do I actually want”, and it beats extracting three candidates into three directories.

Two caveats. It needs FUSE and enough memory and temp space for the archive’s metadata. And it does not reproduce everything, some filesystem flags and ACLs among them, so use mount to find things and extract to restore them properly.

Whichever you use, open a few of the restored files afterwards. An extract that exits zero has told you the archive was readable, not that its contents are what you remember.

If you took rung three, get the key out of the machine

This is the part with no recovery, so it goes before anything else you do with gpg.

A key whose only copy is inside an encrypted repository cannot be reached when you need it. Opening the repository needs the passphrase, the passphrase is encrypted to the key, and the key is in the repository.

gpg --export-secret-keys --armor you@example.com > secret-key.asc
gpg --export --armor you@example.com > public-key.asc
gpg --gen-revoke you@example.com > revoke.asc

Export the public key as well as the secret one. It is not sensitive, and you need it for the paper copy below. gpg --gen-revoke asks you for a reason, a confirmation and the key’s passphrase before it writes anything; keep revoke.asc with the exports, it is what you publish if the key is ever stolen.

For paper, paperkey strips out everything reconstructible from the public key, leaving a much smaller amount to print:

gpg --export-secret-keys you@example.com | paperkey --output secret-key-paper.txt

The default output is a hex dump with line numbers and a checksum on each line, meant to be printed and typed back in, and to tell you which line you mistyped when you do. --output-type raw also exists, but that is binary, for feeding a barcode or QR generator, and is not something you can print and read.

Rebuilding needs the printout and the public key together. paperkey reads only the binary form of the public key, so the first line converts it back; given public-key.asc directly, it prints unable to parse OpenPGP packets and writes an empty file:

gpg --dearmor --output public-key.gpg public-key.asc
paperkey --pubring public-key.gpg --secrets secret-key-paper.txt --output secret-key.gpg
gpg --import secret-key.gpg

Two things people assume about a paper copy that are not true.

It does not remove the passphrase. If the secret key was passphrase-protected, the rebuilt one is too, so paper rescues you from a dead disk and not from a forgotten passphrase.

And it is not self-contained. Without the public key there is nothing to rebuild against, so keep a copy of public-key.asc with the printout.

Store all of it away from the backup drives. Once the copies are safely elsewhere, remove the on-disk secret: shred -u secret-key.asc.

Where this ends up

Doing this by hand for two drives and one folder is perfectly reasonable, and you now know the parts borg will not do for you.

Doing it for four drives and five folders means running the same commands with different arguments, in the right order, remembering the compact after the prune and the check after the create, and noticing the drive that was not plugged in. Which is to say you will write it down, and then you will write a loop, and at that point you have a backup script.

The only question left is whose, and whether you have read it.

Borg 2.0

Borg 2.0 changes the surface enough that the commands here will not run unchanged.

borg init becomes borg repo-create. borg list splits into borg repo-list for archives and borg list for contents. The repo::archive syntax is replaced by -r <repo> plus a separate archive name. borg prune acts on an archive series rather than defaulting to the whole repository.

Debian-family systems still ship the 1.x line at the time of writing. Check borg --version rather than assuming either way.

Next in the section: Creating passphrases, for the passphrases these commands keep asking you for.

Borg on airgapped

  • For: installing Borg on a machine that never touches a network.
  • Before you start: an online machine to download on, and a USB stick.
  • Reading time: about 2 minutes.
  • You end with: Borg installed and verified on the airgapped machine.

Download

On the online machine, download the latest stable release from borg’s releases page. Take the release GitHub marks Latest, not a beta marked Pre-release. The version numbers below are from 1.4.5, the stable release at the time of writing; swap in whatever the current one is.

Download these two files:

borg-linux-glibc231-x86_64.tgz          (~28 MB)
borg-linux-glibc231-x86_64.tgz.asc      (about 1 KB)

Verify

mkdir -p ~/Downloads/borg && cd ~/Downloads/borg
gpg --recv-keys 9F88FB52FAF7B393
gpg --verify borg-linux-glibc231-x86_64.tgz.asc borg-linux-glibc231-x86_64.tgz
gpg --export --armor 6D5BEF9ADD2075805747B70F9F88FB52FAF7B393 > borg-signing-key.asc

Expected output, so nothing surprises you: gpg reports a Signature made <date> line and using RSA key <a long id>. That long key is not the one in the README, and that’s fine: it’s a signing subkey. The line to check is Primary key fingerprint, which must read 6D5B EF9A DD20 7580 5747 B70F 9F88 FB52 FAF7 B393, matching what 00_README.txt on the release page documents. You’ll also get the usual not certified with a trusted signature warning.

Cross-check that primary fingerprint against the footer of any of Waldmann’s BorgBackup mailing list posts before you continue.

Install

If the key matches, copy all three files to the USB stick: the .tgz, the .asc, and borg-signing-key.asc.

On the airgapped machine, verify once more, so the copy that crossed the stick is the copy you checked. cd into the folder that holds the files, e.g. cd /media/john/d1/borg/, and run:

gpg --import borg-signing-key.asc
gpg --verify borg-linux-glibc231-x86_64.tgz.asc borg-linux-glibc231-x86_64.tgz

The Primary key fingerprint line must match the one you cross-checked online. Then install:

sudo tar -xzf borg-linux-glibc231-x86_64.tgz -C /opt
sudo ln -s /opt/borg-linux-glibc231-x86_64/borg.exe /usr/local/bin/borg
borg -V

tar prints nothing; ls /opt then shows one new directory named after the tarball, and the executable inside it really is called borg.exe, even on Linux. If your directory’s name differs, point the ln -s at the one you got. borg -V should print borg 1.4.5, or whichever version you installed.

Creating passphrases

  • For: anyone setting the passphrase on a GPG key, a backup repository or an encrypted disk.
  • Before you start: dice for the strong way, or a machine to run a generator for the easy way.
  • Reading time: about 8 minutes.
  • You end with: one strong passphrase, written on paper and stored in more than one place.

A few passphrases in your life must be treated with utmost care; they must not even be entered into a password manager: the passphrase that unlocks your GPG key and with it your password store; the passphrase on a backup repository; and the passphrase that decrypts your disk at boot. For these passphrases must be available outside the tools that depend on them, so each must be reproducible by you from paper or memory, and each must be unrecoverable by design if lost. Every other password you ever need should come from a generator inside your manager; this document is only for the few that cannot. This is the home for creating such a passphrase and keeping it; the tools that consume them live in their own guides: Getting started with Borg for the backup repository, Full disk encryption with LUKS2 + LVM on Devuan for the disk, and Levelling up: Part 2 for the GPG key.

The stakes, stated plainly

That passphrase is not a small detail; it is the key to everything behind it. If you lose it, what it protects is as inaccessible as if it did not exist. No vendor can reset it and no support line can recover it, and that absence of a recovery path is precisely what makes the encryption worth having.

Write it on paper

Not in a password manager, not in a note on your phone, not in a document on your computer. Write it on paper. Make more than one copy. Store each copy somewhere it would not be found by a casual intruder but would be found by someone who needed it urgently: a sealed envelope in a filing cabinet, a safe-deposit box, a trusted family member’s home. Do not photograph it. Do not type it into any electronic device for the purpose of storage.

This is exactly how hardware Bitcoin wallets work. When you set up a hardware wallet, it generates a list of 12 or 24 random words, your seed phrase, and asks you to write them down on paper. That piece of paper is the only backup of your funds. It has no remote attack surface. The security model is entirely physical, which is a trade-off: you are immune to remote attackers and you are vulnerable to fire, flood, and theft. People who take this seriously make multiple copies and store them in separate locations. The same logic applies here.

Do not invent the passphrase yourself

We humans reach for patterns without realising it. Use Diceware, a method developed in 1995 that lets genuine randomness pick words from a curated list and string them together. The result looks like this: verso droopy flair unmasked shrimp oncoming. Strange, memorable, and effectively impossible to guess. There are three ways to run it.

The easy way: let software roll for you

The simplest route is a generator that picks the words for you from the EFF list using your operating system’s randomness. On Devuan or any Debian-based Linux, install it and run it:

sudo apt install diceware && diceware

On Windows or macOS, install Python and then the same tool:

pip install diceware && diceware

It prints a single line of six words; depending on the version they may come out run together with capitals rather than spaced, and diceware -d ' ' puts spaces between them. KeePassXC’s built-in password generator also has a passphrase mode that does the same job through a graphical interface on all three systems. Six words is the default. This is fine for almost everyone, with one caveat worth stating plainly: the phrase is generated inside your computer, so you are trusting that the machine’s random number generator is sound and that nothing on the machine is watching.

The strong way: roll the dice yourself

If you would rather trust nothing but physics, generate the phrase by hand and let the computer see only the finished result. You need two things: one balanced six-sided die, and the EFF long wordlist printed on paper, which has exactly 7,776 entries, that is 6 to the power of 5, and can be printed from the EFF’s site. That list size is the reason five rolls produce exactly one word with nothing wasted. Roll the die five times and read the results as a five-digit number, for example 4-2-6-1-3. Find that number, 42613, in the printed list, and the word beside it is your first word. Repeat five rolls for each further word. Six words means thirty rolls and about 77 bits, the same strength the software gives, except now no random number generator and no software stood between the dice and your paper. One rule does the heavy lifting: write down the word you land on every time, even a dull or awkward one, and never re-roll for a nicer result, because a re-roll is your own preference climbing back in and quietly draining the randomness you are there to collect. Keep the rolls in order, because the sequence is the number. This is the method to use if you are treating the passphrase as long-term and high-value, and it is the one to default to.

The weak fallback: pointing into a dictionary

You may be tempted by an offline method that needs neither dice nor an install: hold a thick dictionary closed, open it blind, and drop a finger on the page, taking the nearest whole word. This is the weakest of the three and is dominated by the dice method, which is also offline but countable. The same discipline applies, keep the word you land on rather than flipping to a nicer one, but discipline alone does not save it. A blind finger lands evenly across the area of the page, not evenly across the list of words, so it leans towards long words on crowded pages and silently skips anything you do not recognise, both of which pull the result back towards the familiar. The deeper cost is that you cannot measure it. With dice and a fixed list you can state your strength exactly, six words is about 77 bits, because you know how many results the method could have produced; pointing into a dictionary gives you no such number, because your real strength is set by how your finger behaves, not by how many words the book holds. A phrase that looks as though it came from a quarter of a million words may carry far less, the way a lottery ticket printed with eight digits is not one in a hundred million if only a hundred thousand were ever sold. So you are left with no provable floor under your password, which is the one thing dice give you and pointing cannot. If you use it anyway, draw more words than six, eight or more, and treat the strength as a rough margin rather than a measured one; even then it beats any password you would invent unaided. If the larger vocabulary is what tempts you, the way to actually collect it is not a finger on a page but a longer numbered wordlist with more dice per word, which is simply Diceware widened, still mechanical and still countable.

On length and numbers

Six words is genuinely enough for the uses this document serves, and it is worth understanding why rather than taking it on faith. None of these tools feeds your passphrase to the cipher directly. Borg derives the actual key with PBKDF2-HMAC-SHA256 over a random per-repository salt, LUKS runs your passphrase through PBKDF2 or Argon2id before a single block of the disk opens, and GnuPG keeps the private key on disk behind its own iterated, salted derivation. A deliberately slow, salted step like that makes every guess expensive and rules out precomputed tables. Seventy-seven bits behind a slowdown like that is past the reach of any classical attacker, including one who has stolen the drive and is grinding away offline. The twelve-words-and-numbers instinct comes from a different world: a Bitcoin seed phrase is a raw key with no such slow step in front of it, guarding a more exposed and higher-value asset, so it has to carry the full 128 bits in the entropy itself. That is a different scheme, the BIP39 2,048-word list, and its word counts do not transfer here. As for digits and symbols, skip them: a digit in a position an attacker already expects adds only about three bits, where another whole word adds nearly thirteen, so if you want more margin, add a word. The one reason to go beyond six is if you keep a copy on a network where it could be harvested and you want insurance against a future quantum attacker, who could roughly halve the effective strength; eight to ten words covers that, and an offline drive already blunts most of that concern.

One more layer, optional

You can add a word or two of your own that you never type anywhere and that exist only in your memory, laid on top of the generated phrase. Even someone who obtained the generated words would still be missing the ones only you know. Then write the whole phrase on paper, as above.

Inheritance planning

Consider who else could access your backups if you were incapacitated. If you are the only person who knows your backup exists, where it is, and how to decrypt it, then a medical emergency or sudden death makes your data effectively inaccessible to the people who may need it most: family, collaborators, executors. A trusted person should know that your backup exists, where the physical drives are, and where to find the written password. You do not need to hand them the password today; you need to ensure there is a path to it that does not depend entirely on you being available.

The next step: a password manager

Paper works and it is where you should start, but it does not scale. The right tool for managing all your other credentials is KeePassXC, an open-source manager that stores everything in a single encrypted file on your own machine, no cloud account, no subscription. Zaur Molotnikov, a security consultant, independently audited version 2.7.4 in January 2023 and found no major problems in its core cryptography. When you are ready, install it with sudo apt install keepassxc and use it to generate unique passwords for every site you use. Your diceware passphrase becomes the one thing you memorise: the master key that unlocks everything else. For people who live in the terminal, pass by Jason Donenfeld is where this road eventually leads; it uses GPG to encrypt credentials individually and has no graphical interface. But KeePassXC is the starting point.

Terminal basics

  • For: someone who has never opened a terminal.
  • Before you start: nothing.
  • Reading time: about 3 minutes.
  • You end with: enough commands to follow every guide on this site.

If you’ve never used a terminal, here’s what you need to know.

The terminal

The terminal or CLI (Command Line Interface) is just a text window where you type commands and the computer runs them. You type one line, press Enter, see what happens, then type the next. That’s the whole loop. It’s an alternative way of interacting with your computer, instead of clicking buttons in a graphical interface.

Opening the terminal

On macOS, press Cmd+Space to open Spotlight, type “Terminal”, press Return.

On Windows, you’ll need Git Bash, which provides a Linux-like terminal. Download the Git for Windows installer from git-scm.com and run it. After installation, search for “Git Bash” in the Start menu. Don’t use the regular Windows Command Prompt or PowerShell for this guide; they handle these commands differently and some will fail.

On Linux (Ubuntu, Debian, Devuan, most distributions), it’s Ctrl+Alt+T, or find “Terminal” in your applications menu. But if you’re on Linux, you likely know that already.

Getting around

Once the terminal is open, a few commands are enough to get around:

  • pwd prints the current directory (where you are right now in the file system).
  • ls lists the files in it.
  • cd Downloads moves into the Downloads folder.
  • cd .. moves up one level.
  • cd ~ takes you to your home directory, if you ever get lost.

Note: Commands are case-sensitive on Linux, so Ls won’t work where ls does. macOS mostly forgives the case, but type everything lowercase anyway and you’ll never meet the difference.

Working with files

Once you can move around, a handful more commands let you create files, look at them, and tidy up:

  • mkdir notes makes a new folder called notes.
  • cat notes.txt prints a file’s contents to the screen, which is how you check that a file looks right after you create or edit it.
  • cp a.txt b.txt copies a file; to copy a whole folder and everything in it, add -r, as in cp -r olddir newdir.
  • mv notes.txt Downloads/ moves a file into the Downloads folder, and mv old.txt new.txt renames it; either way mv overwrites the destination without asking, so check the name before you press Enter.
  • echo hello prints whatever text you give it, and in guides you’ll usually see it used to write a line into a file, which the guide will spell out when it happens.
  • rm a.txt deletes a file.

There is no recycle bin in the terminal, so rm is permanent and the file is gone the instant you press Enter; never run rm on something you don’t recognise, and be especially careful when it appears next to sudo or with -rf. The -r removes all nested files and subfolders; without it, rm can only delete files, not folders. And -f means do not ask for confirmation, overriding any warnings.

Keys that get you unstuck

A few keystrokes make the terminal far less tedious and pull you out of trouble. The Tab key autocompletes a file or folder name once you’ve typed enough of it to be unique, and pressing it twice lists the choices when more than one matches. The ↑ arrow brings back your previous command so you don’t have to retype it, and pressing it again walks further back through your history; the ↓ arrow takes you the other way. Ctrl+C stops a command that’s running or stuck and hands you back the prompt. The clear command wipes the visible text from your terminal screen, though nothing is actually deleted; your command history is still there (you can scroll up or use the arrow keys).

Running commands as administrator

Some commands change system-wide settings and need administrator rights, which you get by putting sudo in front of the command. The terminal will then ask for your password. The password stays completely invisible as you type it, with no dots or stars to show progress; that’s normal and not a frozen screen, so just keep typing and press Enter.

Reading multi-line commands

When a command on this site spans multiple lines connected by \ at the end of each line, that’s one long command; paste the whole block at once. When multiple lines look separate, run them one at a time.

That’s everything the guides assume. If you came here from another chapter, go back to it; if you’re starting fresh, Whys of backup is first.

Version control overview

For those who have never kept a history of their drafts

With version control, every draft you commit is saved along with the note you wrote on why you changed it, and you can go back to any of them. Whys of version control makes the case, and gives you the four ideas the rest of the section builds on: repository, commit, staging and diff.

Once you’re convinced, jump to Getting started with Git, where you install Git, make your first two commits, and bring back an old version. If you have never opened a terminal, read Terminal basics first; it takes three minutes. Then read Git for writers, which shows how to use Git day to day as a writer. It covers writing commit messages, trying a big change on a branch and dropping it if it doesn’t work, seeing which words changed and not just which lines, keeping drafts in plain text instead of Word, and numbering versions of documents that readers come back to.

Whys, Getting started and Git for writers take about 20 minutes to read, and they’re enough to use Git every day.

The rest of the section is for later. Git concepts explains how commits and branches work, when it’s safe to rewrite history, and how to recover when a command goes wrong. It’s long, but you don’t have to read it in order; when Git does something you didn’t expect, read the section about it. Git reference is organised by task, so you can look up what you want to do and copy the command. Git history delete is a script that permanently removes a file from every commit, for example a file of private notes in a repo you’re about to make public. If the file held a password, change the password first; Never commit secrets explains why.

Whys of version control

  • For: writers and other knowledge workers who have never used version control.
  • Before you start: nothing.
  • Reading time: about 4 minutes.
  • You end with: why Git is worth learning, and its four core ideas.

This article introduces and explains important concepts of Git that every knowledge worker must know. How did you get by without it even, really?

Git: what it is and why it matters

Imagine you are writing a long essay. You finish a draft on Monday, revise it on Wednesday, rewrite the introduction on Friday, and by the following Tuesday you realise the Wednesday version of the second paragraph was actually better than what you have now. But it’s gone. You typed over it. The only version that exists is the one on your screen.

This is the primary problem Git solves.

Git is the best version-control tool invented so far. It takes snapshots of your files whenever you tell it to. Each snapshot is called a commit, which, essentially, is just a version of the file or files you chose to save. You can make an indefinite number of versions of a file and record why the changes were made.

The power of saving versions

There is a difference between a backup and a version history. A backup saves a file. A good version-control system allows you to save an indefinite number of versions of a file and information on why a change was made, thus showing you both how the file changed over time and why. A backup says: “This is what the file looked like on 3 March 2027.” A version history says: “This is what the file looked like at 10 am on 3 March 2027, and here’s what it looked like at 5 pm, and here is what you changed from the previous versions, and here is the note you wrote to yourself at those times explaining why you made those changes.”

A backup protects you against loss. A version history gives you a record of your own thinking.

Consider what it would mean to maintain a single file, say, your personal notes on a subject you care about, across ten years of committed revisions. You would be able to open that file at any point in its history and see not just what you believed then, but what you thought was worth changing, what you added, what you removed, and what you said about each change. Every commit message is a small act of self-examination: you are forced to articulate, even in a sentence, what you did and why.

Over a decade, this accumulates into something genuinely valuable. You could trace the moment you abandoned a position. You could see when a new reading reshaped your understanding. You could watch a paragraph appear, survive three rounds of revision, and then quietly vanish from the document a year later, and read your own note explaining why you cut it. This is intellectual autobiography at the level of the sentence.

And because identical content across versions is stored only once, with delta compression underneath for the rest, the storage cost is negligible. A decade of daily commits on a text file might occupy a few megabytes. So you don’t have to choose between versions; you can keep all of them, permanently. So edit away.

Four core concepts

There are only four ideas you need to understand before using Git. Everything else is detail.

A repository, or “repo”, is a folder that Git is tracking. It’s a normal folder on your computer: your files plus a hidden .git subfolder where the history lives. You never need to open or touch that subfolder. You interact with Git either through commands in a terminal or through a visual application.

A commit is a snapshot, a saved state of all tracked files at a moment in time. Every commit has a unique identifier (a long string of letters and numbers), a timestamp, your name, and a message you wrote.

Staging is the act of telling Git which changes you want to include in your next commit. You might have changed three files but only want to snapshot two of them. Staging lets you choose. You stage changes first, then commit them.

A diff is a comparison between two versions of a file. It shows you exactly which lines were added, removed, or changed. In most visual diff tools, additions are highlighted in green and deletions in red. This is the core of what makes version history useful: not just seeing what the file looked like, but seeing precisely what changed.

Benefits of Git

Recovering a lost version is only the first application. The same history gives you several more:

  • Every tracked file carries its full past, so you can watch the evolution of your ideas and notes over time, down to the individual line.
  • It allows you to compare any two versions of a file, see exactly what differs, and take the parts you prefer from either side, line by line if you want.
  • It allows for easy collaboration. Hand a copy to anyone, let them edit freely, and see precisely how their version differs from yours before deciding what to keep. This is handy when you’re working with an AI and want it to review or change something for you. You can easily tell what changed. Imagine the pain of reviewing and comparing without Git.

As a bonus, you get tamper resistance. You control what change you allow in. And because every committed change is sealed to its contents and ancestry, a change to any previous commit requires every commit after it to be changed.

Each commit carries a summary and, if you do it right, a message describing why the file changed; so when others and your future self look back, they aren’t baffled by why the change was made. Commit summaries and commit messages are embedded in every commit, along with the name and email you choose to provide (they don’t have to be your real ones).

Next, read Getting started with Git, where you use these four ideas for the first time.

Getting started with Git

Learn the basics of Git: install, commit, compare, restore.

  • For: writers and other knowledge workers who have never used Git.
  • Before you start: Whys of version control, and the terminal (see Terminal basics).
  • Reading time: about 6 minutes.
  • You end with: Git installed, a folder under version control, two commits, and an old version brought back.

1. Install Git

On Debian-based Linux

sudo apt install git -y

On macOS

Apple ships Git with its command line tools. Install them:

xcode-select --install

A window asks whether to install the tools; click Install, agree to the licence, and wait a few minutes. If the terminal says the command line tools are already installed, you already have Git. If you installed Homebrew for Borg, brew install git works too.

On Windows

If you followed Terminal basics, you installed Git for Windows to get Git Bash, and Git came with it. Run everything on this page in Git Bash.

Whichever route you took, run git --version; if it prints a version number, you’re ready.

2. Create the repo

A repo is just a folder Git is tracking. You have to do this step only once per folder.

2.1. Name the main branch

Run this once, ever:

git config --global init.defaultBranch main

It prints nothing. Git calls a line of history a branch, and this names the first one main in every repo you create from now on. Without it, the next command may print a long hint about the name master; it’s harmless, but this makes it go away for good.

2.2. Make the folder a repo

Make a folder for your writing, move into it, and turn it into a repo:

mkdir ~/writing
cd ~/writing
git init

Git replies with something like Initialized empty Git repository in /home/your-name/writing/.git/. That hidden .git folder is where the history lives; you never need to open it.

2.3. Tell Git who you are

From inside the folder, run:

git config user.name "Your Name"
git config user.email "you@example.com"

Both print nothing. Every commit is stamped with this name and email, and they don’t have to be your real ones. Without --global, they apply to this repo only; Identity setup in Git reference shows how to bind one identity to a whole folder of repos once you have more than one.

3. Commit

Create a file. In the terminal, echo with > writes a line into a file, creating the file if it isn’t there. Any text editor works just as well; save the file in ~/writing.

echo "The first line of my essay." > essay.md

Now ask Git what it sees:

git status

It lists essay.md in red under “Untracked files”: Git sees the file but isn’t keeping its history yet. Stage it, then commit it with a one-line message:

git add essay.md
git commit -m "Start the essay"

You should see something like this:

[main (root-commit) 4b6a9ed] Start the essay
 1 file changed, 1 insertion(+)
 create mode 100644 essay.md

Congratulations. That’s your first commit: a snapshot of essay.md as it is now, with your message attached. 4b6a9ed is the start of its unique identifier; yours will differ. The message says what the commit does, in the imperative (“Start”, not “Started”), short and with no full stop; Commit message discipline explains why.

4. Change and compare

Now change the file. >> adds a line to the end of a file instead of replacing it:

echo "A second line, added on Wednesday." >> essay.md

Before you commit, look at exactly what changed:

git diff
diff --git a/essay.md b/essay.md
index 0ee63b7..e9e29ee 100644
--- a/essay.md
+++ b/essay.md
@@ -1 +1,2 @@
 The first line of my essay.
+A second line, added on Wednesday.

Ignore the first five lines for now. The line with no sign is unchanged, shown for context. The line starting with +, in green, is what you added; a line you deleted would start with -, in red. That’s a diff: exactly what changed since your last commit.

Stage and commit, the same two steps as before:

git add essay.md
git commit -m "Add the Wednesday line"

That’s the whole loop: change, look at the diff, stage, commit. Everything else in Git builds on it.

5. Get an old version back

5.1. Find the version

List your commits, newest first:

git log --oneline
acb427b Add the Wednesday line
4b6a9ed Start the essay

If the list is longer than the screen, Git shows it a page at a time; press q to get back to the prompt. Copy the identifier of the version you want, here 4b6a9ed, the first one.

5.2. Look at it

To read the file as it was in that commit, without changing anything, give the identifier, a colon, and the file’s name:

git show 4b6a9ed:essay.md

It prints The first line of my essay. You can copy a passage you miss straight out of the terminal.

5.3. Bring it back

Caution

Commit before you do this. git restore overwrites the file, and any change you haven’t committed is gone for good.

To put that version back into the file itself:

git restore --source=4b6a9ed essay.md

It prints nothing, and cat essay.md shows the old version. git status now lists essay.md as modified: the old text is in your file, and the history is untouched. Stage and commit it if you want to keep it, or return to the latest version with:

git restore essay.md

Either way, nothing was lost: every version you committed is still in the history. Congratulations. And that’s how easy it is to get an old version back.

If you’d rather see changes in an editor than in the terminal, Git concepts shows how in VSCodium and in vim.

Next, Git for writers: what all of this means when the files are prose.

Git for writers

  • For: writers who keep their work in Git, or are about to.
  • Before you start: Getting started with Git.
  • Reading time: about 10 minutes.
  • You end with: habits for committing prose, branches as a place to experiment, readable diffs for paragraphs, and a version-number scheme for documents readers come back to.

Everything about Git applies to writers as much as to programmers; Git doesn’t care what your files contain. But a writer’s relationship to version control has some specific textures that developer-oriented tutorials rarely name. This page is the pivot.

The deepest principle survives the translation: Git is a communication tool, not a backup tool. The conversation it enables is with your future self. For a writer, this is actually more evocative than for a programmer, because writers already know viscerally what it means to lose an earlier version. The paragraph you cut and wish you hadn’t. The opening you wrote six drafts ago that was somehow closer to the truth than where you ended up. Git is, fundamentally, the ability to never actually lose any version of anything while still being free to cut ruthlessly in the moment.

Commit messages as craft notes

The “why not what” rule has a different flavour for prose. The diff shows which words changed; it doesn’t show whether you changed them because the rhythm was off, because a reader said the paragraph confused them, because you realised the character wouldn’t actually speak that way, or because you were tired and second-guessing yourself at midnight. The message is where you record the intention behind the revision, and for a writer that intention is often artistic or emotional rather than technical.

Good commit messages for prose might look like:

  1. “Cut the second section; felt like throat-clearing.”
  2. “Rewrote opening after workshop feedback. Tessa was right, it was starting in the wrong place.”
  3. “Restored earlier version of dialogue; the revised one lost the flatness I wanted.”

These are notes to yourself about craft. Years later, when you’re wondering why a piece evolved the way it did, these messages are a record of your own thinking as a writer. Worth having for its own sake, separately from the practical use of finding a specific version.

Lucia Berlin’s notebooks, Carver’s drafts, Didion’s edited manuscripts: we treasure these because they reveal the process. Git gives you, the writer, the ability to keep your own version of this record effortlessly, for your own later study or simply as a form of self-knowledge about how you actually work.

Rhythm of commits

For a developer, a commit often corresponds to a completed small task. For a writer, commit at natural pause points: end of a writing session, after a significant revision, after a workshop meeting when you’ve absorbed feedback, after you’ve “finished” a draft in the provisional way any draft is ever finished.

Don’t try to commit after every sentence. The unit of meaningful change in prose is usually the paragraph, the scene, the session.

Do commit before any major surgery. Before you cut a whole section, commit. Before you restructure, commit. Before the “what if I rewrote this in first person” experiment, commit. The freedom to experiment radically comes from knowing the previous version is safe.

The “one commit, one logical change” principle translates as: don’t mix unrelated revisions in the same commit. If you sat down to fix typos and ended up also rewriting the ending, those are two different creative acts and deserve two different commits. This matters when you want to look back and remember what you changed and why.

Branches as creative experiments

Branches change meaning for a writer, and arguably become more interesting than they are for most developers. In code, branches are a logistical tool. In writing, branches are a tool for actual creative experimentation. The ability to say “let me try rewriting this story from the sister’s point of view, fully, not just as an exercise but as an alternative version I can live inside for a while, while keeping the current version completely safe.”

Make a branch. Do the experiment. If it works, merge that version in or keep both as separate artefacts. If it doesn’t, delete the branch and return to exactly where you were. The commands are under Branches in Git reference, and the idea underneath them is Branches as pointers in Git concepts.

Writers often resist radical experiments in revision because they fear losing what they have; branches dissolve that fear entirely. This is probably the single most transformative use of Git for creative work, and it’s underappreciated because most Git tutorials are aimed at developers who think of branches more prosaically.

Making prose diff well

Git’s default diff works at the line level. For code that’s fine; code is structured by lines. For prose, a line often contains an entire paragraph, and a one-word change shows the whole paragraph as removed and the whole new version as added. The actual change drowns in noise.

There are two fixes. They solve the same problem from opposite directions, and the right choice depends on whether you want to change how you write or how you read.

The write-time fix is semantic line breaks: put each sentence on its own line in the source file. Markdown renders identically whether sentences sit on separate lines or flow in a paragraph (a single newline becomes a space; a blank line becomes a paragraph break), so the rendered output is unchanged. But the source-level shape is now line-oriented in the way Git already expects. Insert a sentence and you touch one line. Reword a sentence and the diff shows that one line changed, not the whole paragraph. The default git diff does the right thing without any configuration. This is strictly better for diffs and costs nothing at render time. New users almost never know this convention exists, because it’s invisible in the rendered output; it only pays off once you start reviewing your own history. Many serious Markdown-based documentation projects on GitHub follow it for exactly this reason.

The trade is editor-side: the source looks ragged in a plain editor, and some auto-formatters will reflow it back into paragraphs (configure them not to, or disable on Markdown). For documents you’ll diff repeatedly across years, the rag is worth the legibility.

The read-time fix is --word-diff, which tells Git to highlight changes at the word level even when whole paragraphs are on one line:

git diff --word-diff
git diff --word-diff=color

Git has no setting that makes word-level diffs the default, so give the command a short alias instead:

git config --global alias.dw "diff --word-diff"

From then on, git dw gives you the word-level view in any repo. One-time setup in Git reference includes the same alias among its others.

GitLens in VSCodium has similar settings for word-level or character-level diffs.

The two approaches compose: a repo written with semantic line breaks and read with word-diff gives you the cleanest possible prose diffs at both granularities. The line-level diff shows which sentences moved or changed; the word-level view shows what changed inside those sentences. If you only do one, do semantic line breaks. It’s tooling-free, travels with the file rather than the config, and works in every Git interface including forge web views.

Plain text, not Word

For serious writing work, write in plain text formats (Markdown, reStructuredText, plain text, LaTeX) and only convert to Word or PDF for delivery. Plain text is what Git is built for. Word documents technically work but you lose most of the benefit, because you can’t see what changed. Git stores the whole binary as “this file changed” without being able to show a meaningful diff.

If you must use Word (an editor insists, a contract requires it), do the editing in plain text, convert to Word at the final step, and commit the Word file at that point. Version the Markdown. Deliver the Word.

Versioning prose documents

The commit log as changelog, in Git concepts, concerns commit-level history: every commit is a potential version, and the log is the changelog. A separate question is whether documents (essays, reference notes, theological writing) should carry an explicit version number on top of that.

Code projects answer this with semantic versioning, usually called semver: a three-number scheme MAJOR.MINOR.PATCH where MAJOR signals a breaking change, MINOR signals a backward-compatible feature, and PATCH signals a bug fix. For documents the breaking-change concept doesn’t translate literally (prose doesn’t compile), but it translates cleanly with one substitution.

The mapping for prose

Translate “breaking change” to “thesis shift”.

  1. MAJOR: the claim changed. Your view of the subject has moved. Someone who cited the previous version to support an argument may find their citation no longer supports it. This is the prose equivalent of a breaking API change, because readers who built on your earlier conclusion now have work to do.
  2. MINOR: the content changed but the claim didn’t. You added a section, cut a digression, expanded an example, brought in a new source. The thesis is intact. Readers who remember what you argued don’t need to re-read; they only need to look at the new version if they want the fuller treatment.
  3. PATCH: the copy changed. Typo fixes, clarified wording, reformatted a list, corrected a date. No change in meaning, no change in content. Readers don’t need to do anything.

The mnemonic is three Cs: claim, content, copy. Claim is the argumentative term; content is the body of substance; copy is the publishing-industry term for the text itself.

Pre-1.0 translates cleanly. 0.x.y is a draft you’re not yet willing to commit to publicly. 1.0.0 is the point at which you’re standing behind the thesis. After that, every revision records at what level you’ve shifted.

Worked example

A theology essay on covenant theology, first published as 1.0.0.

  1. You catch a typo in a footnote, fix a quotation that had the wrong translation, and tighten an awkward sentence. Bump to 1.0.1. Copy only.
  2. A month later you add a section on a minor critic you hadn’t addressed, cut a digression on an adjacent topic, and expand one example. The thesis hasn’t moved. Bump to 1.1.0. Content.
  3. A year later you read a Reformed author who convinces you that one of the load-bearing claims was wrong, and you now argue a modified position. Bump to 2.0.0. Claim.

A single revision can cross categories. If you fix typos and also add a new paragraph in the same pass, the bump is the highest level involved: 1.1.0, not 1.0.1 and then 1.1.0. A MAJOR bump resets MINOR and PATCH too: 2.0.0 starts fresh at .0.0, not carrying forward the prior minor count.

When to use it

Version numbers are useful only when readers come back. A blog post nobody returns to doesn’t need versioning; a reference doc people cite does. The break-even is whether someone would need to know whether what they remembered is still what’s there. If yes, version. If no, don’t.

For personal writing in a Git repo, the commit history already records every change, and filename suffixes (_v2, _v3) mark coarse milestones for workflow reasons. Semver on top of that is a public-facing contract for readers who don’t read the commit log. The two schemes don’t conflict; they operate at different layers.

Honest gotchas

The claim/content/copy line isn’t always clean. A new section you add might implicitly shift the claim by strengthening one side of an argument you didn’t mean to weight more heavily. If the effect on the thesis is material, bump MAJOR even though mechanically you only added content. The question is always what the reader would conclude, not what you technically changed.

“Breaking change” for prose is softer than for code. Code either compiles or it doesn’t; a reader’s citation either supports their argument or it doesn’t, but the line is judgment-bound. Default to the stricter call: if you’re unsure whether the change is MAJOR or MINOR, call it MAJOR. Over-signalling is cheap; under-signalling breaks trust.

Most prose doesn’t accumulate enough revisions to make fine-grained versioning worth the overhead. The minimum worthwhile use case is something you expect to revise at least a few times a year, across a few years, for an audience that might refer back.

Recommendation

For any document you plan to maintain and expect to have return readers, use semver with the claim/content/copy mapping. Start pre-1.0 while drafting; release at 1.0.0 when you’re willing to stand behind the thesis publicly; bump per the rules above on every revision. State the scheme once in a footer or about-page; don’t re-explain it per document.

For ephemeral writing (blog posts, one-off essays, anything readers won’t revisit), skip versioning entirely. Filename suffixes in your own repo are enough for you; readers don’t need to see them.

Don’t let tooling become procrastination

The entire apparatus of version control, tooling, configuration, organisational scaffolding is in service of the actual writing. It’s very easy to spend a whole afternoon perfecting your Git setup as a way of not confronting a difficult scene.

Set things up well once. Establish simple habits. Let the system fade into the background. The writing is the thing. Git is the frame that keeps the writing safe and legible across time. If you find yourself thinking about Git more than writing, you’ve inverted the relationship.

You now know enough to use Git for your writing. If you want to know why Git behaves the way it does, read Git concepts; you don’t have to read it all at once.

Git concepts

  • For: knowledge workers who want to understand Git rather than memorise it.
  • Before you start: Getting started with Git.
  • Reading time: about 73 minutes end to end. The sections stand alone; read the ones you need.
  • You end with: the mental model behind commits, branches, staging, history rewriting and identity, and the habits that keep a history useful.

This page explains important concepts of Git that every knowledge worker must know. The four core ones (repository, commit, staging, diff) are in Whys of version control; this page goes underneath them.

How commits actually work

The object model is worth understanding once, early. It will make every later operation make sense rather than feel arbitrary.

When you commit, Git creates a small number of objects in its internal storage. For a repo containing three files that you just committed for the first time, Git creates five objects:

  1. Three blobs. A blob is the raw content of a file, compressed. One blob per unique file content: the blob carries no filename and no permissions (those live in the tree entry that points at it), so two identical files share one blob, and a file that doesn’t change between commits keeps pointing at the same blob. Any edit, however small, produces a whole new blob; Git snapshots content, it does not store diffs at this level.
  2. One tree. A tree is a list of filenames paired with the blob (or sub-tree) that holds that file’s contents. It’s essentially Git’s version of a directory listing.
  3. One commit object. The commit object points to the root tree (representing the full project state at that moment), carries the metadata (author, date, message), and points to the parent commit or commits. A normal commit has one parent. The initial commit has no parent. A merge commit has two or more parents, one for each branch being merged.

The commit object is the anchor. Given a commit, you can reconstruct the entire state of the repo at that moment by walking from the commit to its tree, from the tree to its blobs, and from sub-trees to their nested blobs. Sub-trees are how directories work: a tree entry pairs a name with a hash, and that hash points either at a blob (a file) or at another tree (a subdirectory). A repo containing notes/ch1.md has a root tree with an entry notes pointing at a second tree, and that second tree has an entry ch1.md pointing at the blob holding the chapter’s text. Reconstruction is a recursive walk: start at the commit’s root tree and descend until every entry bottoms out in a blob. One pleasant consequence: a subdirectory you didn’t touch keeps the same sub-tree hash from one commit to the next, so deduplication happens at directory granularity too, with a single reused pointer covering any number of unchanged files. The commit also points backward to its parent, which points to its parent, and so on, all the way back to the initial commit, which doesn’t point to a parent commit because it doesn’t have one. That chain of parent pointers is the history: git log is just a walk along it.

Two consequences fall out of this model.

First, Git’s deduplication is structural rather than engineered. Because an object’s address is the hash of its own content, identical content cannot be stored twice: if you don’t change a file between two commits, both commits’ trees point to the same blob. Systems whose storage isn’t content-addressed have to build duplicate-avoidance as machinery (Subversion stores file changes as deltas between revisions; Mercurial’s revlogs are per-file delta chains; backup tools like borg and restic implement explicit chunk-level deduplication); in Git it falls out of the addressing scheme. The one engineered layer Git does add is delta compression inside packfiles, which squeezes similar-but-not-identical blobs during garbage collection; that runs underneath the model and changes nothing about it. This is why ten years of commits on a slowly-evolving document occupy negligible space.

Second, every commit is immutable. A commit object is identified by a cryptographic hash of its own contents. Change anything about it (the message, the author, the parent, even a timestamp) and the hash changes, which means it’s now a different commit. This is why “rewriting history”, the family of operations that replace commits with edited copies (amending the last commit, rebasing a series; both covered in Rewriting history below), is technically a misnomer: you don’t edit old commits, you create new commits that replace them. Old commits still exist in the database until garbage collection eventually removes them, which is why the reflog can still find them weeks later. One corollary, stated directly: two commits can carry an identical title and still be distinct commits, because the message is only one of the hashed inputs, and their trees, parents, or timestamps differ. A busy repo accordingly accumulates many commits titled Merge branch 'main'.

Branches as pointers

A branch in Git is a lightweight movable pointer to a commit. That’s it. The default branch name is usually main (older repos use master, and so may Git itself unless you set init.defaultBranch).

When you make a commit, Git creates a new commit object whose parent is the commit you were previously on. Then it moves the current branch pointer forward to the new commit. Every time you commit, the branch pointer advances automatically.

One more pointer completes the picture: HEAD. HEAD is Git’s you-are-here marker; it normally points at a branch, which points at a commit, and committing moves the branch while HEAD rides along. HEAD~1, HEAD~2, and so on count backward from wherever HEAD is, which is why the reference doc uses them as the standard way to name recent commits. HEAD can also point directly at a commit instead of at a branch, the state Git calls detached HEAD, which you enter when you check out an old commit to look around. Detached HEAD is a read-mostly state, not an error: looking is free, but a commit made there belongs to no branch, so it becomes unreachable the moment you switch away (recoverable from the reflog for thirty days by default). If you decide to build on an old commit, create a branch right there first; the warning Git prints on detaching says exactly this.

Creating a new branch is just writing a new pointer. It costs nothing. Deleting a branch is just removing a pointer; it doesn’t delete the commits the branch pointed at (those remain reachable through other branches, or through the reflog).

This is why Git culture encourages using branches liberally. They are not expensive objects. They are small files containing a commit hash. Create one every time you want to try something. If it works, merge it; if it doesn’t, delete it and the experiment vanishes cleanly from your workflow while the commits themselves remain in the reflog for thirty days by default as a safety net.

The mental model: your main branch (usually called main) should always be in a known-good state. Every piece of in-progress work happens on its own branch, isolated from everything else, and only gets integrated into main when it’s ready. This feels like overhead on day one and feels like oxygen by month six.

Staging, explained

One distinction comes before staging: tracked versus untracked. A file Git has never been told about is untracked; it shows up in git status, but it belongs to no snapshot, and nothing in Git protects or records it until the first git add. That first add is what turns a file from invisible to tracked, and only tracked files participate in anything this document describes. The classic first-week surprise follows directly: git commit -a stages and commits modified tracked files only, so a brand-new file silently stays out of the commit until it has been added once. When a commit seems to be missing a file, check git status for it under “Untracked files” before suspecting anything deeper.

The staging area was designed for a workflow that most solo users, and especially beginners keeping personal version history, don’t actually have. Git was originally built for Linux kernel development, where a single developer might be working on several unrelated changes simultaneously and needs to package them into separate, clean commits before sharing them with other people.

Imagine you sit down to fix a bug, and while you’re in there you also notice a typo in a comment, and you decide to rename a variable for clarity. You’ve now made three logically unrelated changes to your working files. If you commit them all together, the history becomes muddy; someone reading it later sees “fixed bug” but the commit also contains the rename and the typo fix, which makes it harder to understand what actually fixed the bug, and much harder to undo just one of those changes later.

The staging area lets you say: “Of all the changes sitting in my files right now, include only these specific lines in the next commit.” You stage the bug fix and commit it with a clean message. Then you stage the typo fix and commit that separately. Then the rename. Three clean commits, each doing one thing, each reversible on its own. That’s the point of staging: it’s a workbench where you assemble a commit before finalising it, rather than being forced to commit everything that’s currently different from the last commit.

This matters enormously on collaborative projects where other people will read your history, review your commits, and potentially revert individual ones. It matters less when you’re keeping a personal save-point history of your own work.

Solo workflows are usually “I’m working, I want to save this version, let me commit, now I can keep going, then commit again.” You can absolutely use Git that way. The command git commit -a skips the staging step entirely and commits every tracked change in one go. In GUIs, most of them have a “stage all and commit” button or a checkbox that selects all changes at once.

There are four levels of staging granularity, from least to most precise.

File-level. You’ve modified three files and want to commit only two. Stage the two with git add file1 file2, commit; the third file stays in your working directory, unstaged, waiting for a future commit.

Hunk-level. A single file contains two unrelated changes; you want to commit one, not the other. Use git add -p file, which walks you through each “hunk” (a contiguous region of changed lines) and asks y/n/q/a/d/s/e/?:

  1. y stages this hunk.
  2. n skips it.
  3. q quits.
  4. a stages this and all remaining hunks in the file.
  5. d skips this and all remaining hunks.
  6. s splits the current hunk into smaller hunks when Git has grouped unrelated changes together.
  7. e opens the hunk in your editor for line-by-line selection.
  8. ? shows help.

Line-level. Inside the e option of git add -p, you can hand-pick individual lines. In the editor, deleting an added line (one starting with +) excludes it from staging. Changing a removed line’s - to a space keeps the deletion in your working directory without applying it to this commit. Fiddly the first few times, surgical once you’re used to it. VSCodium’s “Stage Selected Ranges” (Seeing file changes in VSCodium, below) is the same mechanism with a better interface.

Stage-everything. git commit -a -m "message" grabs every tracked modified file, stages it all, and commits in one shot. The workflow for people who don’t want to think about staging.

Staging is not a one-way door. git restore --staged file unstages without changing the file’s contents. The staging area is a scratch pad, not a commitment.

When staging actually matters for solo work

Honest answer: for a personal project, line-by-line staging is useful rarely. Most of the time, on solo work, you’ll commit everything and move on. The elaborate machinery exists, but the situations that actually demand it are uncommon in solo workflows.

Here’s where it earns its keep, even alone. The scenario is almost always the same shape: you sat down intending to do one thing, got distracted or curious along the way, and now your working directory contains two or three logically separate changes mixed together. You fixed a bug, but while you were in that file you also cleaned up some formatting, and you started sketching a new feature that isn’t working yet. If you commit all of that together with a message like “bug fix and other stuff,” you’ve lost the ability to do a few things later. You can’t revert just the bug fix without also reverting the formatting and the half-done feature. You can’t look back at the history and understand cleanly when or why any one of those changes happened. And if the half-done feature turns out to be a dead end, you can’t throw it away without losing the bug fix that’s tangled up with it.

Staging lets you untangle that mess at commit time. You stage and commit the bug fix with a clear message. You stage and commit the formatting cleanup with its own message. The half-done feature stays in your working directory, uncommitted but not lost, and you keep working on it or discard it later.

The second real use case is more specific: the “oh no” moment. You’ve been working for two or three hours. Things are partly working and partly broken. You realise you want to save the parts that work before you break them further, but you don’t want to commit the broken parts because committing broken code even to your own history is annoying. Staging lets you pick out the working parts, commit them as a solid checkpoint, and keep fiddling with the broken parts without fear.

A realistic usage pattern on a personal project: ninety percent of the time, commit everything together because your changes are coherent. Maybe eight percent of the time, use file-level staging because two files are changed for unrelated reasons. Maybe two percent of the time, reach for git add -p because you actually need to split changes within a single file. The line-by-line editing option gets used a handful of times per year. The tool exists for when you need it.

Don’t try to learn staging up front as an abstract concept. Commit everything together with git commit -a -m "message" or the equivalent GUI button, and get comfortable with the basic rhythm. Eventually you’ll hit one of the scenarios above, and at that point staging will stop feeling like arbitrary complexity and start feeling like the right tool. Learn it then. The understanding sticks much better when it’s attached to an actual problem you’re trying to solve.

Seeing file changes in VSCodium

If a visual diff is easier for you to read than terminal output, VSCodium gives you a Git workflow that makes the diff, the staging step, and the commit itself legible at a glance. The Source Control panel built into VSCodium covers the daily cycle on its own; the GitLens extension adds blame, history, and visualisation on top.

Built into VSCodium

Open the folder that contains your repo. VSCodium auto-detects the .git folder inside and enables the Source Control panel without any extension installed.

The Source Control icon sits in the left sidebar: a branching-line icon. Click it. The panel has two main sections: “Changes” (files you’ve modified but haven’t staged) and “Staged Changes” (files you’ve added with git add). Above them, a text box for the commit message, and a checkmark button to commit.

Click a changed file to see the diff. The diff opens in the main editor, showing two columns: the last committed version on the left, your current version on the right. Green highlights are additions; red highlights are deletions. Lines with no highlight are unchanged context.

To stage a whole file, hover over its name in the Changes list and click the + that appears. The file moves down to Staged Changes.

To stage a specific hunk within a file (when you only want to commit part of the file’s changes), hover over the hunk in the diff view. A small + appears beside it. Click it. That hunk stages; the rest remains unstaged.

To stage individual lines, select them in the diff, right-click, and choose “Stage Selected Ranges.” This is the visual equivalent of git add -p with the e (edit) option covered in Staging, explained above. Surgical control, done by mouse selection instead of editor surgery.

Once you have what you want staged, type a message into the text box at the top of the Source Control panel, then either click the checkmark or press Ctrl+Enter. That’s a commit.

What GitLens adds

Install GitLens from the Extensions panel (search for “GitLens, Git supercharged”; the publisher is eamodio on Open VSX, maintained by GitKraken).

GitLens adds several further capabilities worth knowing:

  1. Inline blame annotations: the editor shows, for the line your cursor is on, who last changed it, when, and with what commit message. If it becomes distracting you can toggle it off: GitLens: Toggle Line Blame.
  2. Gutter indicators: thin coloured bars in the left margin of the editor mark which lines have been added, modified, or deleted since the last commit. A quick visual of what you’ve changed in the open file. VSCodium draws these on its own; GitLens adds a heatmap of how recently each line changed.
  3. File history: right-click any file and choose “Open File History” to see every commit that touched it, with previews of each version.
  4. Commit graph: a full visualisation of your branch structure. Useful when history gets tangled. Free for local and public repos; on private repos, this and several other features need a GitLens Pro subscription.
  5. Compare: you can compare any two branches, or any two commits, and see a combined diff.

This much is enough to replace most command-line usage for a solo writer or developer. You still drop to the terminal for complex history surgery (filter-repo, reflog recovery, ad-hoc shell tooling), but for the daily edit-stage-commit cycle the GUI is faster and more legible than the terminal.

Seeing file changes in vim

If you edit in vim, the diff view should also be in vim. A terminal pager like git-delta is a step backward when your editor is already a better diff viewer than any pager: pagers are read-only, they don’t honour your keybindings or colorscheme, and you can’t act on what you see.

The dominant tool in vim culture is Tim Pope’s vim-fugitive plugin. With your repo open in vim, run :Gdiffsplit for a horizontal split or :Gvdiffsplit for a vertical one. You get the indexed version of the current file alongside the working-tree version, both as real vim buffers, with synced scrolling, automatic folding of unchanged regions, and full editability on either side. Vim’s diff-mode keybindings worth memorising:

  1. ]c jumps to the next changed hunk; [c jumps to the previous.
  2. do (diff obtain) pulls the change at the cursor from the other buffer into the current one.
  3. dp (diff put) pushes your version into the other buffer.
  4. :Gwrite (fugitive) stages the current buffer (equivalent to git add on that file).

The combined effect: read the diff, decide hunk by hunk what stays and what reverts, stage the result, and commit, all without leaving vim.

If you don’t want plugins, use vimdiff directly. Configure it as Git’s diff tool once:

git config --global diff.tool vimdiff
git config --global difftool.prompt false

Then git difftool <file> opens that file’s diff in vim, and git difftool with no argument walks every changed file in turn. Same ]c, do, dp keybindings; you just don’t get fugitive’s tighter integration with git add, git blame, and the rest. For comparing arbitrary files outside Git, plain vimdiff file1 file2 works with no setup.

Which tool when

The principle: use the diff tool tied to whichever editor you actually live in. The argument is the same regardless of editor: if you can read the diff and edit either side without leaving the tool you already use all day, the friction of context-switching disappears and the keybindings carry over. This doc covers vim and VSCodium concretely; other editors (Sublime, JetBrains, Helix, emacs with magit, Zed) have analogous integrations and the principle generalises.

If you edit in vim, fugitive’s :Gdiffsplit is the strongest combination of viewer and editor available anywhere. Both sides are buffers in your editor, you can edit either, you can stage from inside, and the keybindings are the same ones you use all day for everything else. Nothing else in this list matches it for someone fluent in vim.

If you edit in VSCodium, the built-in Source Control panel plus GitLens is the strongest editor-integrated option available there. The diff view shows committed and working versions side by side, you can edit the working side directly, and hunk-level staging is one click.

GitHub’s web diff is a different category. It’s optimised for reviewing other people’s pull requests, with line-level comments and “suggest changes” boxes. You can’t see your own uncommitted local work there at all (you have to push first), and the editing affordances are minimal. Use it for code review of other people’s work, not for your own daily edit-stage-commit cycle.

git-delta and similar terminal pagers (diff-so-fancy, diffr) sit below all of these. They prettify the read-only diff output you see when you run git diff or git show in a terminal. Useful when you’re already in the terminal and want a more legible pager than less. Not a workflow tool; it’s a nicer view, nothing more.

Plain git diff piped through default less is the floor: zero setup, ugly but unambiguous, and what you’ll see when you ssh into a server you haven’t configured. Worth being comfortable with for that reason.

A two-line decision rule. If you’re editing the file, use the tool tied to your editor. If you’re just reading, use whichever pager is in front of you.

Commits as communication

Here is the principle that underlies nearly every piece of specific advice that follows: Git is not a backup tool, it’s a communication tool. It happens to also back up your work, but that’s a side effect. What it’s really doing is creating a record that communicates your thinking to other people, and the most important “other person” you will ever communicate with through Git is your own future self. Every decision about how to use Git flows from taking that seriously.

Linus Torvalds, the Bitcoin Core maintainers, the Linux kernel community: what makes their use of Git exemplary isn’t technical sophistication, it’s that they treat every commit as a message to future readers. Once that principle is internalised, most of the specific practices become obvious.

Commit message discipline

From that principle, the first concrete habit: write commit messages that explain why, not what. The diff already shows what changed. Anyone can read that. What the diff can’t tell them is why you made the change.

“Fixed bug” is useless. “Fixed off-by-one error in pagination that caused the last item to be skipped when total count was exactly divisible by page size” is a gift to the future.

The canonical format, from Git’s own documentation and Tim Pope’s 2008 note (in the reading list below), adopted by most serious projects:

  1. Summary line of fifty characters or less.
  2. Blank line.
  3. Longer explanation in paragraphs, explaining the context, the reasoning, and any caveats.

Three further conventions go with that format:

  1. Imperative mood for the summary. “Add login redirect,” not “Added” or “Adds.” It reads as completing the sentence “If applied, this commit will…”. This is what git itself uses for its own commits (“Merge branch”, “Revert”).
  2. No trailing period on the summary. It’s a one-line label, not a sentence. The period adds visual noise without information.
  3. Body wrapped at seventy-two characters. Pairs with the fifty-character summary rule. git log indents the body by four spaces (4+72=76, fits an 80-column terminal); email patch workflows quote-prefix replies, and longer lines break across wraps; git format-patch and similar tools assume it. Without the wrap, git log and any email-based review render ragged.

A full example that obeys all of the above, for code:

Fix dropped last page on exact-multiple counts

The page count used integer division of total by page_size, which
dropped the final page whenever total was an exact multiple of
page_size. A request for the last page returned empty and the item
was never rendered.

Round up so a full final page is always emitted, and add a
regression test covering the exact-multiple boundary.

Closes #214.

The summary is imperative, under fifty characters, and has no trailing period. A blank line separates it from the body. The body explains why the bug existed and what the fix does, wrapped near seventy-two, and a trailer points at the issue.

The same shape for prose drops the issue trailer and records intent rather than mechanism:

Cut the second paragraph of the intro

It restated the thesis the opening already carried and slowed the
entry into section 1. The argument reads tighter without it.

You don’t need to follow this rigidly on personal projects, but you should absorb its spirit. Every commit message should answer the question: “if someone finds this commit in a git blame three years from now while debugging, what do they need to know?”

Several short pieces are worth reading once and internalising.

Chris Beams, How to Write a Git Commit Message. The seven rules with worked before-and-after examples; the canonical modern how-to.

David Thompson, My favourite Git commit. A one-character whitespace fix whose message documented the error, the investigation, and the fix, later found by others running git log --grep who learned who had hit it before and how they solved it.

Michael Lynch, No Longer My Favorite Git Commit. The rebuttal to the piece above: the same commit buries its key point too deep, which is the case for putting the load-bearing line first.

Tim Pope, A Note About Git Commit Messages (2008). The origin of the fifty/seventy-two/imperative conventions above; nearly universal in serious projects.

A common temptation is to skip the body because the reasoning already lives somewhere else: a changelog, a versioning file, an issue, a design note. The message and an external file are not interchangeable, for three reasons.

Locality: git blame and git log -L land you on a specific line tied to a specific hash, with the message right there (both tools are walked through in Reading history: log and blame, below); an external file forces a second lookup, and assumes it still matches the change and was not reorganised since.

Binding: the message is hashed into the commit and cannot drift, whereas an external file is mutable and over years drifts away from the diffs it once described.

Audience: a changelog is reader- or release-facing, whereas a commit message is maintainer-facing at the grain of one change.

The resolution is a pointer, not duplication. If the deep rationale genuinely belongs in another file, let the body reference it (an issue number, a doc path, a changelog anchor) rather than restating it. What stays non-negotiable is the summary line, because that is what blame and log surface regardless. A body that is only a durable pointer is fine when the summary is load-bearing; a dead summary like “update” or “fix” is not, because the external file will not be in front of you when the tool puts you on the line.

A related question is which whys belong in the document’s own text rather than a commit at all. The split that holds is current state versus transition. Rationale about the current state, the thing every future reader of the document needs in front of them, belongs in the prose: it should be visible without running a single Git command. Rationale about a transition, why you changed something from what it was, belongs in the commit: it is tied to that specific change, and surfacing it in the document would clutter the living text with the history of decisions already made. A why that you judge not worth surfacing in the document is usually a transition why, and the commit is its proper home.

A further question: does a change so obvious it explains itself need a message at all? The subject line, always: Git refuses an empty message, and a dead subject like “update” defeats every tool that surfaces it. The body, only when it carries a why the diff cannot show; “Fix typo in § Hooks” is complete with no body, because the diff is the explanation. The trap is judging “obvious” by diff size. The most celebrated message in the reading list above, David Thompson’s favourite, decorates a one-character whitespace diff, and the body was the entire value. Test by recoverable why, not by size: if a stranger reading the diff in three years would still ask why, write the clause. When in doubt, one sentence of why costs less than the doubt.

A related scoping question: when the repo holds many files and a commit touches one, should the summary name the file? As a rule, no: the summary names the change, not the file list. Every tool that surfaces the summary sits next to a tool that surfaces the files (git show, git log --stat, git log -- <path>), so a filename in the summary spends scarce characters on what the diff already carries, and the fifty-character budget is better spent on intent. The earned exception is the area prefix, the subsystem: summary convention from large repos: the Linux kernel writes net: sched: fix refcount leak because in a tree that size a summary is unreadable without locating context, and the prose analogue is ch2: tighten opening in a book-sized repo. Use the prefix when the change is meaningless without its location; never use the summary as a file inventory. For session-grain commits the dated enumeration already plays this role, and the path-prefixed body line shown in the session-grain subsection below scopes a why to one file inside the bundle.

For the day-to-day mechanics (the aliases git c, git ca and git last, the commit.verbose setting, the commit message template, and the editor configuration that makes all this comfortable), see One-time setup in the Git reference doc.

One commit, one logical change

Each commit does one thing. This is where the discipline of staging becomes valuable, not because you have to use it on every commit, but because the principle of “one commit, one logical change” is what makes history valuable decades later.

A commit that mixes a bug fix, a refactor, and a new feature is nearly impossible to reason about in isolation. You can’t revert part of it. You can’t understand it at a glance. You can’t cherry-pick it to another branch.

Small, focused commits are the atoms of a useful history.

Rule of thumb: if you find yourself writing “and” in a commit message, you probably should have made two commits.

Concretely: one sitting produces a bug fix and a rename, and the single message would have been “Fix dropped last page and rename PageHelper”. Split, it becomes two commits, “Fix dropped last page on exact-multiple counts” (the commit shown in full in the previous section) and “Rename PageHelper to Paginator”, each revertable, readable, and cherry-pickable on its own.

Commit often, curate before sharing

Two practices for handling the gap between “save often” and “history should be legible.” Which fits depends on the repo. For shared code, multi-author projects, anything that gets reviewed before merging: commit messily while working, then curate before pushing (the workflow below). For solo writing, single-author docs, exploratory personal projects with no shared history to clean up for: commit at session grain with dated messages (the subsection at the end of this section). Mixed-author writing projects sit in between: session-grain locally is fine, but commits that ship as releases or get pointed at externally are worth curating. Pick the model that fits the repo’s audience; don’t try to apply both.

This resolves what otherwise feels like a contradiction. “Small focused commits” sounds incompatible with “just save your work as you go.” The answer is that your local in-progress history doesn’t have to be clean. Commit messily while you’re working, whenever you hit a checkpoint, with throwaway messages like “wip” or “trying this.” Then, before you push or share, use interactive rebase (git rebase -i) to reshape that messy sequence into clean, logical commits with real messages.

Squash the typo fixes into the commits they belong to. Split large commits that do too many things. Write proper messages. This is the workflow experienced maintainers use, and it’s what lets their public history look so crisp even though their actual working process is as chaotic as anyone’s. The private draft is scruffy; the published version is polished.

Session-grain commits

Not every project needs the curate-before-share workflow. Solo writing, exploratory personal notes, long-running single-author docs: there’s no shared history to clean up for, so the curation step adds friction without much payoff. For these workflows the defensible practice is the dated work-session commit, used as the primary commit grain rather than as scratch to be rewritten later.

The format is a date plus a brief enumeration of areas touched:

2026-05-28 — ch3 revisions, footnotes, intro tightening

The date alone is redundant with what git log shows; the value is in the enumeration after the em dash, which makes git log --grep=footnote or git log --grep=ch3 find the sessions where you touched a given area. The commit isn’t atomic (it bundles whatever a writing session produced), and the message names the bundle honestly without pretending otherwise.

Two things this is not. It is not a substitute for one-logical-change commits when the work is shareable; for code or collaborative projects, curate. It is not an excuse to skip git diff --staged before committing: even a session-grain commit benefits from a glance at what’s actually in it, especially to catch accidentally-staged secrets or junk files.

Scoping a why to one file inside a bundled session commit. A session commit gathers several files, but a commit still carries exactly one message; there is no per-file message field. There are two ways to attach a why to a single file without unbundling the rest. Put the why in the body on a line prefixed with the file’s path, so git log -- path and git blame surface it against that file later. Or split that one file into its own commit, so its message is its why, and commit the remaining files under the session line separately. Which to use is a discoverability call: history views and inline blame show only the subject line, so a why in the body is found by git log --grep or blame, not by scanning git log --oneline. A light note rides fine in the body; a why heavy enough that a future reader must not miss it is the signal to give that file its own commit, where the message is bound to the change and shows up on its own.

Concretely, the two shapes. The why riding in the session body, prefixed with the file’s path:

2026-06-08 — ch2 pass, refs cleanup, typo sweep

notes/ch2.md: cut the Hodge block quote; it was secondhand via
Berkhof, replaced with a direct citation.

And the same why promoted to its own commit, with the session commit shrinking around it:

Cut the Hodge block quote from ch2

It was secondhand via Berkhof; replaced with a direct citation so
the chapter quotes Hodge at first hand.
2026-06-08 — refs cleanup, typo sweep

The commands for both, multiple -m paragraphs and a path-scoped partial commit, are in the Git reference doc.

The hierarchy that matters: curated atomic commits are best when there’s shared history, session-grain dated commits are a legitimate practice for solo work where curation buys nothing, and unlabelled wip or update commits across years are the failure mode this section exists to prevent. Pick the grain that fits the work; commit at that grain deliberately.

Rewriting history

Git lets you rewrite history, with caveats. The mechanisms:

For the most recent commit:

git commit --amend -m "new message"

This replaces the message on the last commit you made. Without -m, git commit --amend opens your editor to write a longer message. You can also amend to add forgotten changes: stage the changes, then git commit --amend --no-edit folds them into the previous commit without touching the message.

For older commits:

git rebase -i HEAD~N

-i is interactive. HEAD~N means “go back N commits.” Git opens an editor showing those commits as a list, each prefixed with pick. Change pick to:

  1. reword to change the message.
  2. squash to fold the commit into the one above it (combining both messages).
  3. fixup to fold in without keeping the message.
  4. edit to stop at this commit and amend it.
  5. drop to delete the commit entirely.

Reorder commits by moving lines. Save and close, and Git walks through your instructions.

The interactive rebase above is the general tool. For the specific case of “commit C is missing a small change,” there’s a cleaner shortcut: the fixup + autosquash workflow. Stage the change, then git commit --fixup=<C> records a new commit with the message fixup! <subject of C>. When you later run git rebase -i --autosquash <C>^, Git pre-positions the fixup next to C and pre-marks it for squashing; you confirm by saving the editor. This is the dominant workflow on rebase-based projects (Bitcoin Core, the Linux kernel) because it skips the manual reorder-and-relabel step that plain interactive rebase requires.

Note that --amend itself only operates on HEAD. There’s no syntax to target an older commit; for anything beyond HEAD Git itself needs rebase, and the fixup pair is the path of least friction.

Outside Git, git-revise does the same fold in one command and in memory: it applies your staged changes to the commit you name, or rewrites that commit’s message, without touching the working tree or the index, and a run that fails leaves everything as it was. It has no conflict tooling beyond a prompt and doesn’t run your hooks, so the fixup pair stays the fallback when a fold conflicts. The commands are under Revising older commits in the reference.

For merges specifically, interactive rebase across them requires --rebase-merges:

git rebase -i --rebase-merges HEAD~5

Without that flag, rebase flattens out merges, which you probably don’t want.

The safety rule

Rewriting is safe when the commits only exist locally. The moment you’ve pushed to a shared remote, rewriting causes problems. Other people’s copies of history diverge from yours, and reconciling it requires force-pushing, which can destroy their work if they’ve built on top of the commits you rewrote.

For a purely solo project, or where you’re the only one pulling, rewrite freely. For anything shared, the rule of thumb: rewrite only commits that haven’t been pushed, and leave pushed history alone.

When you must force-push (amended a commit and need the remote to accept the new version), use --force-with-lease --force-if-includes:

git push --force-with-lease --force-if-includes

The lease refuses the push if the remote has moved since you last fetched, so you can’t overwrite work you didn’t know had been pushed. On its own it has a gap: any fetch, including the one inside a git pull that then stopped on a conflict, resets what the lease compares against, so the push goes through and deletes the commit you fetched but never took in. --force-if-includes (Git 2.30+) closes the gap by also refusing unless your branch contains everything the remote has. The reference’s git pf alias uses both. Use the pair instead of plain --force by default.

There’s a lighter-weight alternative: git notes. Notes let you attach additional commentary to a commit without rewriting it. The commit itself is untouched; you add a note alongside it with git notes add -m "additional context" <sha>. Safe on pushed commits because it doesn’t alter the commit. Downsides: notes don’t show up in most views by default, and they don’t travel between repositories automatically without extra configuration. A niche tool, useful occasionally.

The reflog as safety net

git reflog is Git’s safety net. It’s a log of where HEAD has been, so if you ever feel like you’ve lost work through a bad rebase or a hard reset or some other destructive operation, the reflog almost certainly still has the commit you’re worried about losing.

New users panic about “losing” weeks of work, and it’s almost always recoverable through the reflog. Knowing this exists lets you experiment fearlessly, because Git very rarely actually loses anything: it just moves references around. The reflog keeps a record of every move: by default for ninety days, or thirty for commits that no branch can reach any more, which is exactly what a bad rebase or reset leaves behind.

The typical recovery pattern:

git reflog                                # find the sha you want
git reset --hard <sha>                    # go back to it
# or:
git branch recovery <sha>                 # save it as a new branch

If you ever find yourself in a state you don’t understand after a destructive operation, stop before running more commands. Read the reflog. The panic-repair cycle is where people actually lose work, not the original mistake.

Reading history: log and blame

Good commit hygiene pays off only if you actually read history. git log and git blame are the tools that make it worth your while.

git log --oneline --graph --all --decorate is the one flag combination worth memorising. Compact visual history of every branch. Aliasing it as git lg (see Configuring Git well on day one below) saves typing. A sample of what it shows, output illustrative:

$ git lg
* 3f2a91c (HEAD -> main) Tighten §2 opening
* 8c41d07 Add covenant-of-works subsection
| * a19be4f (experiment/first-person) Recast ch1 in first person
|/
* 5d20e83 Cut second intro paragraph
* 2b7f410 Initial commit

One line per commit, branch and HEAD labels inline, and the graph column showing where the experiment branch forked from main.

git log -S "string" (pickaxe) searches history for commits that added or removed a specific string. This is how you find “when did this text first appear in the document?” or “when did I delete that paragraph?”

git log --author="name" and git log --since="2 weeks ago" filter the log by who and when.

git blame <file> annotates every line with the commit that last touched it. Combined with a good commit message, the blame tells you not only when a line was written but why. This is where the investment in commit messages compounds. Again illustrative:

$ git blame -L 12,14 notes/ch2.md
8c41d07 (jdoe 2026-05-30 14:02:11 +0530 12) The covenant of works frames
8c41d07 (jdoe 2026-05-30 14:02:11 +0530 13) the argument: obedience as the
3f2a91c (jdoe 2026-06-02 09:41:38 +0530 14) condition, life as the promise.

Three lines, two commits: the first two last moved in the subsection commit, the third in a later tightening pass, and git show 3f2a91c surfaces the why behind it.

For the full option surface, the manual pages are authoritative: git-log and git-blame. Pro Git’s chapter on viewing history is the best guided tour of git log’s filtering flags.

The habit of looking at history when debugging, “when did this break? what changed around then? why?”, is where Git transforms from a save-point tool into something closer to a time machine.

The commit log as changelog

Before version control, projects maintained changelogs by hand. A file called CHANGELOG, CHANGES, or NEWS sat at the root of the source tree, and the maintainer added an entry to the top of it at each release: version number, date, a few bullets of what changed. GNU projects have used NEWS files for this since the 1980s; many still do. The “Keep a Changelog” convention (2014) codified a Markdown format for the same practice.

The problem with hand-written changelogs is drift. The changelog lives parallel to the actual history. Maintainers forget to update it. Entries get written at release time from memory, which is unreliable and selective. The changelog says one thing, the commits say another, and the commits are right.

Git subsumes the raw version of this work. The commit log is the changelog, generated automatically from the commits you’ve been making all along. For any two points in history, git log v1.0.0..v1.1.0 --oneline gives you every commit between them in order, with no effort. git shortlog -sn v1.0.0..v1.1.0 groups them by author. git log --since="1 month ago" gives a month’s worth. These generate the changelog; they do not require you to maintain a changelog file.

What Git doesn’t replace is the curated release note. A commit log is for an engineer investigating history. A release note is for a user deciding whether to upgrade. Converting the former into the latter is still a writing task: grouping by category (features, bug fixes, breaking changes), discarding commits users don’t care about (typo fixes, internal refactors), and translating developer language into user-facing language. The difference is you’re editing a generated draft rather than reconstructing from memory. You can’t miss a commit because the tool is listing all of them.

Tools that bridge the two levels. git log v1.0.0..v1.1.0 --oneline as the starting draft. On GitHub, gh release create v1.1.0 --generate-notes auto-drafts release notes from commits and PRs since the last tag, which you then edit. Conventional Commits (a convention that prefixes commit messages with feat: or fix:, and marks breaking changes with a !, as in feat!:) lets tools like git-cliff or release-please auto-group entries by category; worthwhile for projects that ship often, overkill for solo writing.

For a personal writing repo, the commit log is the changelog, full stop. You don’t need a CHANGELOG.md file; readers who care about change history read git log or click “History” on the host’s web view. If you want a human-readable summary when you ship a revision, generate it from git log at that moment; don’t maintain it in parallel.

Per-file history is the same idea at finer grain. git log --follow <path> shows every commit that touched a specific file, following renames across its lifetime. On GitHub, https://github.com/<user>/<repo>/commits/<branch>/<path> is the web rendering of that log. A blog or reference site can expose this URL as a “History” link in the footer of every page; Simon Willison’s til.simonwillison.net is the reference implementation.

The decision space behind that link is wider than it looks. Two questions organise it. First, who decides what counts as a change: git, which makes no distinction between a typo fix and a paragraph rewrite, or a human curator, which is editorial but requires discipline you have to maintain forever. Second, who renders the diff: the host (GitHub, Codeberg, GitLab all render commit diffs in their web UI), or you (your build script generates diff pages on your own site).

Three families fall out. Host-linked: computer decides, host renders. The pattern above. For Markdown files on GitHub specifically, the commit page has a “Display the rich diff” toggle that renders the Markdown and highlights word-level changes inline, close to the prose-diff quality of purpose-built tools like NewsDiffs. Self-rendered: computer decides, you render. Same git walking, but your build generates per-page diff pages on your own site. Worth the work when the repo can’t be public or you want full control of the reader’s experience; otherwise overkill. Hand-written: human decides. A CHANGELOG.md in Keep a Changelog format, or a per-post “Revisions” block at the foot of each post. Same drift problem as the release-note case.

For a personal writing repo, host-linked is almost always the right pick. Five lines of template, the host does the rendering, and the reader gets a real history view; on GitHub specifically, the rich-diff toggle on each commit gives a rendered prose view (worth a one-line awareness hint in the template).

A note on what “version” means for a blog post. Git has no version concept for an individual file; every commit is a potential version. Three habits keep the history view manageable: squash related micro-commits before pushing so each row is one substantive change; write commit messages that read as reader-facing changelog entries, because they are; and prefix non-content commits (typo fixes, formatting tweaks, metadata edits) with a token like [trivial] so a build script can filter them when computing the displayed “Last updated” date. Without that filter, a typo fix advances the date on a two-year-old post whose actual content hasn’t moved.

In a log, the filter’s input looks like this (output illustrative):

$ git log --oneline -- content/covenant.md
9b3c1e2 [trivial] Fix two typos in covenant post
4f08a77 Add section on Kline's critique
e1d92c0 Publish covenant post

The build script skips the [trivial] row when computing the date, so “Last updated” stays on the Kline addition.

The lightweight alternative is to skip per-page history machinery and put corrections inline. A bracketed [Edit YYYY-MM-DD: ...] aside placed where a now-wrong claim sits catches the reader at the sentence the correction applies to, no clicking required. For posts you rarely revise, inline notes are sufficient on their own; the host-linked approach earns its place only when revisions are frequent enough that readers want to inspect them systematically.

Publish a blog builds the host-linked version into its Zola script: the SHOW_HISTORY_LINK setting adds a “View history” link to each post.

Configuring Git well on day one

A dotfile of accumulated wisdom. The commands below compound enormously over years. Set them once; later sections refer to this as the day-one block.

git config --global init.defaultBranch main
git config --global core.editor "codium --wait"
git config --global diff.tool vimdiff
git config --global difftool.prompt false
git config --global pull.rebase true
git config --global push.autoSetupRemote true
git config --global fetch.prune true
git config --global rerere.enabled true
git config --global commit.verbose true
git config --global commit.template ~/.gitmessage
git config --global core.hooksPath ~/.git-hooks

What these do:

  1. user.name and user.email are left out of the block on purpose: they’re stamped on every commit, and the recommended pattern is to omit both from the global config, set user.useConfigOnly = true, and bind the identity to a directory via includeIf gitdir:, so a repo created outside that directory fails loud rather than committing under whatever was lying around in global config. See Identity layers and isolation for the threat-model reasoning, and Identity setup in Git reference for the commands.
  2. init.defaultBranch main: new repos use main instead of master.
  3. core.editor: what opens for commit messages and interactive rebase. VSCodium example; substitute your editor.
  4. diff.tool vimdiff plus difftool.prompt false: sets the diff tool for git difftool and skips its per-file confirmation. Substitute another tool if you don’t edit in vim.
  5. pull.rebase true: pulls rebase onto the remote instead of creating merge commits. Linear history, less clutter. The deeper treatment is in Pull behaviour below.
  6. push.autoSetupRemote true: the first push of a new branch creates the remote branch and sets the upstream automatically (Git 2.37+), instead of refusing until you run git push -u origin <branch> once.
  7. fetch.prune true: every fetch drops remote-tracking branches whose remote branch was deleted, so git branch -a reflects reality instead of accumulating ghosts.
  8. rerere.enabled true: “reuse recorded resolution.” Git records merge-conflict resolutions and replays them automatically when the same conflict recurs. The deeper treatment is in rerere below.
  9. commit.verbose true: when you commit, the staged diff appears in the editor below your message (stripped on save). Lets you describe what’s actually changing rather than what you remember changing.
  10. commit.template ~/.gitmessage: pre-fills the commit editor with reminders of the 50/72/imperative rules as comment lines (stripped on save). The reference doc has the template content.
  11. core.hooksPath ~/.git-hooks: redirects Git’s hook discovery from the per-repo .git/hooks/ to a user-managed directory, so the same hooks apply to every repo on your machine. The hooks themselves are covered in Hooks below.

Useful aliases, added under [alias] in your global ~/.gitconfig. The block below is a representative subset; the Git reference doc has the full set, grouped by purpose, with reliability notes.

[alias]
    c = commit -m
    last = show --compact-summary HEAD
    lg = log --oneline --graph --all --decorate
    st = status -s
    co = checkout
    br = branch
    unstage = restore --staged
    caas = commit --amend --no-edit
    pf = push --force-with-lease --force-if-includes

The first two are the everyday pair: git c "msg" commits staged changes and git last shows what you just committed (commit metadata, message, and a compact summary of changed files including new/deleted/mode-changed annotations). The reference’s git ca "msg", which stages everything and commits in one go, is left out here because it depends on the reference’s idle guard, which stops it while a merge or rebase is half done. The pf alias uses --force-with-lease --force-if-includes, which refuses to overwrite the remote if someone else has pushed in the meantime; never alias plain push --force to a short name. For the fuller setup that goes with these (commit.verbose, the commit message template, the editor configuration), see the Git reference doc.

Most experienced users have a dotfile of Git configuration that’s grown over years and represents accumulated wisdom about friction points. Start yours now; add to it whenever you find yourself typing the same flag combination twice. The reference doc is the single source of truth for the alias inventory; come back here only for the principle, not the list.

Remotes and the distributed model

Everything so far happens inside one folder on one machine, and that is the complete system: Git is distributed, meaning every repo is a full standalone copy carrying the entire history, not a client of some server. A remote is just a bookmark: a name in your repo’s config, conventionally origin, pointing at the URL of another copy, whether on a forge like GitHub or Codeberg, on a server of yours, or in another directory on the same disk. Nothing you do locally (commits, branches, rebases, the lot) leaves your machine until you explicitly push. For a writer that is worth saying plainly: a Git repo is private by default, and version control does not imply publishing.

Four verbs cover all the traffic. clone copies a remote repo to your machine, history and all, and wires up origin for you. fetch downloads what the remote has that you don’t and updates your remote-tracking pointers (origin/main), touching none of your own work; it is the look-don’t-touch verb. pull is fetch plus integrate: it folds the fetched commits into your current branch, and how it folds them is the subject of the next section. push uploads your commits and moves the remote’s branch pointer forward, which the remote accepts only when your history contains its current tip, so you cannot silently overwrite work you haven’t seen.

Your branch and the remote’s copy of it are separate pointers that drift apart whenever either side moves. Git tracks the pairing (your main against origin/main, called the upstream), and git status reports the drift as ahead, behind, or diverged: ahead means you have commits to push, behind means commits to pull, diverged means both.

Diverged is where merge conflicts can appear. When both sides changed the same lines and Git is asked to integrate them, it stops, marks the overlapping region in the file, and waits for you to choose; nothing is lost, both versions are right there in the marked block. A conflict is Git declining to guess between two valid versions, not an error, and on solo single-machine work you may not meet one for months. The mechanics, markers and all, are in the reference; the integration choice that decides how often you meet them is next.

Pull behaviour: rebase vs merge

The day-one block sets pull.rebase true. The setting changes how git pull integrates remote work with yours, and the choice has consequences worth understanding.

What git pull does without pull.rebase true

By default, git pull is git fetch followed by git merge. If you have local commits that the remote doesn’t, and the remote has commits that you don’t, the merge step produces a merge commit joining the two lines: a commit with two parents, a message like “Merge branch ‘main’ of <remote>”, and no actual content change of its own. Run this often enough and the log becomes a forest of these no-content merge bubbles. They’re real history (Git is faithfully recording that two divergent lines were joined) but they’re noise: nobody wrote those messages, nothing about them is worth reading later, and they obscure the actual commits.

What pull.rebase true does instead

It changes the second step from merge to rebase. After fetching the new remote commits, Git takes your local commits, sets them aside, fast-forwards your branch to the new remote tip, and replays your local commits one by one on top. The result is a linear history: remote commits first, then your local commits, no merge bubble. The log reads as if you’d worked sequentially on the latest version of main all along.

The cost: your local commits get new hashes. The replay produces new commit objects, because each new parent changes the hash. This is fine for unpushed work (the old hashes only existed locally), and it’s the operational form of the rule “rewrite local history freely, never rewrite pushed history without coordination.” If you’ve already pushed those local commits to a shared branch, the rebase will diverge from the remote and the next push will need force, which is a different kind of problem.

A rebasing pull also needs a clean working tree: with uncommitted changes, it refuses to start until you commit or stash them.

When rebase-on-pull is the wrong default

On a branch where you have merged in commits other people already have (another shared branch, say), rebase-on-pull flattens your merge and replays those commits on top with new hashes, so your history ends up carrying copies of commits other people have already based work on. The merge-on-pull default avoids that by keeping every commit stable. Most modern workflows don’t have this problem (feature branches typically belong to one author), but if you work that way, switch the setting per-repo with git config pull.rebase false for that repo.

What neither setting protects you from

A worry that sounds related but isn’t: what if the remote commits themselves are ones you don’t want? Neither pull variant helps, because both integrate them; after either kind of pull, your branch contains the remote’s work, and merge versus rebase only chooses the topology of the join. The guard against suspect remote commits is inspection before integration: git fetch, then git log HEAD..origin/main to list what arrived (or git diff HEAD...origin/main to read it as one diff), then decide what to do. No alias is needed for the deciding, because git pull already takes per-invocation flags that override the configured default for that one pull: git pull --rebase, git pull --no-rebase, and git pull --ff-only.

There is also a third configuration stance worth naming: git config --global pull.ff only. With it, a pull succeeds only when your branch can fast-forward; any divergence makes the pull refuse, and you then choose --rebase or --no-rebase explicitly for that case. This is the fail-loud setting, the same philosophy as the identity setup in Identity layers and isolation: nothing is integrated silently, and every divergence becomes a deliberate decision. Its cost is friction in the common case, where the divergence is your own work on two machines and rebase was always going to be the answer. The recommendation here stays pull.rebase true for that reason; if you would rather decide every time, ff-only is the principled way to get it.

When a pull stops halfway

Either kind of pull can stop on a conflict, and then it is half done: Git has fetched the remote’s commit and started combining it with yours, but your branch doesn’t contain it yet. That’s why every push keeps failing, and why the push error’s advice to pull doesn’t help: the pull already happened, and what’s missing is its second half. git status says which state you’re in, and the only ways out are to finish (resolve each file, git add it, then commit or continue) or to abort, which puts you back where you were before the pull. The mode changes two things: a rebase replays your commits one at a time, so you can meet a conflict once per commit, and inside a rebase --ours and --theirs swap meaning, because “ours” is then the commit being built on, which is the remote’s. The commands are under If a pull stops on a conflict in the reference.

The habit that avoids most of these is pulling at the start of a session, before you edit anything: with nothing local to combine, a pull just fast-forwards. What usually gives a pull something to combine is a commit you didn’t make on this machine, made in the forge’s web editor or pushed from another clone; the reference shows how to tell which.

The pull-rebase debate, in one paragraph

This is one of the contested edges flagged in the closing caveats. The “always rebase” camp values clean linear history; the “always merge” camp values not rewriting commits already shared with anyone. Both are defensible. The recommendation in this document, pull.rebase true as a global default, fits the most common modern pattern (feature branches owned by one person, merged to main via pull request), and is what most contemporary practitioners run. Adjust per-repo if your situation is different.

rerere

rerere stands for “reuse recorded resolution.” The day-one block enables it. It’s quiet and useful: quiet enough that you may use Git for years without realising it’s working.

What rerere records

When you resolve a merge conflict, Git saves the pre-resolution conflict text and your post-resolution result in .git/rr-cache/. If the same conflict (the same conflicting hunks) appears later, Git applies your earlier resolution automatically.

When rerere actually fires

It triggers only on identical conflict hunks: if either side of the conflict differs even slightly, the cache doesn’t match and you resolve manually again. Changes elsewhere in the file don’t matter. In practice this means rerere pays off when:

  1. You rebase a long-lived feature branch against a moving main and the same conflict re-emerges each rebase.
  2. You’re a maintainer integrating recurring patches and seeing the same merges play out.
  3. You’re cherry-picking the same commit across multiple branches.

For solo personal work with occasional conflicts on stable files, rerere will almost never fire. It costs nothing to leave enabled and pays off quietly when a workflow gets repetitive enough to need it.

Knowing when rerere helped

The replay is quiet but not silent: Git prints Resolved '<path>' using previous resolution. and still stops, so you check the file and git add it as usual. While it’s stopped, git rerere status lists the paths rerere is tracking, and git rerere diff shows the resolution itself. If a recorded resolution turned out wrong (the surrounding code changed in a way that makes the old resolution incorrect), forget the cache entry with git rerere forget <path> and resolve manually.

Hooks

A Git hook is a script Git runs automatically at a specific point in the workflow. Hooks let you enforce policy locally: warn before committing under conditions you specify, lint files before push, format code on staging, abort a commit that violates a rule.

Hooks live as executable files in a directory Git inspects when relevant operations run. The default location is .git/hooks/ inside each repo. Files there with the right name (pre-commit, prepare-commit-msg, pre-push, post-merge, and others) get executed at their respective stages.

Three facts about hooks worth absorbing early:

  1. Hooks are per-machine. The .git/ folder isn’t tracked by the repo itself. Cloning a repo does not give you its hooks. Each developer sets up their own hooks on their own machine. This is by design: hooks run arbitrary code, and pulling a repo would otherwise mean executing whatever the repo’s author put in their hooks. Tools like pre-commit (the framework) layer a tracked configuration over this so hooks can be shared, but the raw mechanism is local-only.

  2. Hooks are opt-in. A fresh repo’s .git/hooks/ directory contains only .sample files, which Git ignores because of their names. Git runs nothing unless you create executable hook scripts with the exact hook names yourself.

  3. Which hooks run depends on the command, not only on the name. pre-commit runs on git commit but not on the commit git rebase --continue makes for you; prepare-commit-msg runs on both. --no-verify skips pre-commit and commit-msg but not prepare-commit-msg. And tools that write commits themselves, such as git-revise, run none of them. A check meant to catch mistakes while you finish a conflict therefore belongs in prepare-commit-msg, which is where the reference puts its conflict-marker check.

core.hooksPath

To share hooks across all your repos rather than copying them into each one, set core.hooksPath to a user-managed directory (the day-one block sets this to ~/.git-hooks). Every repo on the machine then reads hooks from that single directory instead of its own .git/hooks/. Add an executable hook there once and it applies everywhere. One-time setup in Git reference uses this pattern to install a hook with two checks: it refuses a commit that adds conflict-marker lines, and it warns when a rename commit also contains substantial content changes (the failure mode covered in Renames, under What Git silently discards).

The trade-off: per-repo override is lost. If one repo genuinely needs different hooks, drop a .git/hooks/<name> in that repo and point core.hooksPath back at it for that repo with git config core.hooksPath .git/hooks. (Running git config --unset core.hooksPath there does nothing: the setting lives in your global config, not the repo’s.) For most personal workflows, the global pattern is right; the rare per-repo override is the exception.

.gitignore hygiene

A .gitignore file at the repo root tells Git which files never to track. Build outputs, dependencies, credentials, OS-generated clutter (.DS_Store), editor config, log files.

GitHub maintains a repository of language-specific templates at github.com/github/gitignore. For any new project, grab the relevant template as a starting point.

Global ignore for OS and editor clutter that should never be tracked, regardless of repo:

git config --global core.excludesfile ~/.gitignore_global

Example global ignore:

.DS_Store
Thumbs.db
*.swp
.idea/
.vscode/

If you’ve already committed a file that should be ignored, adding it to .gitignore won’t untrack it. You need:

git rm --cached path/to/file

This removes it from Git’s tracking without deleting it from disk. Commit the removal plus the .gitignore update together.

Committing things that don’t belong in version control (compiled binaries, node_modules, API keys) is one of those mistakes that’s annoying at best and catastrophic at worst. The .gitignore habit protects you from the whole class of problems.

Line endings

Line endings are an invisible footgun that fires the moment a repo crosses operating systems. Windows uses CRLF (\r\n) to terminate lines; Linux and macOS use LF (\n). Git stores whatever’s in the file. If one collaborator edits on Windows and another on Linux, every line of every text file will show as changed on the next diff, because the invisible \r characters appear and disappear with each save. The diff becomes useless: the actual change you made is buried under thousands of phantom line modifications.

Even solo work is exposed if you ever clone a repo that was edited cross-platform, or if you switch machines, or if some collaborator opens a file in a Windows editor and saves it back.

The fix is a .gitattributes file at the repo root. The core line:

* text=auto eol=lf

text=auto lets Git detect text vs binary files. eol=lf declares that text files should be stored and checked out with LF endings. The same file also handles per-pattern CRLF overrides for Windows-specific scripts and binary markers; see the Git reference for the combined template.

.gitattributes is committed to the repo, so every clone gets the same rules. This is different from .gitconfig settings (core.autocrlf, core.eol), which are per-machine and don’t travel with the repo. Prefer .gitattributes for anything that should apply consistently across collaborators.

If you’ve already accumulated mixed line endings in a repo, fixing them is a one-time renormalisation:

git add --renormalize .
git commit -m "Normalize line endings"

This rewrites every text file’s stored form to match the .gitattributes rules. After this commit, future diffs are clean.

The cost of setting this up on day one is two lines in a .gitattributes file. The cost of not setting it up and discovering the problem six months in is debugging phantom diffs across hundreds of files. Add the .gitattributes before the first commit.

Never commit secrets

API keys, passwords, private keys, tokens. Once committed, they exist in the history forever. Even if you remove them in a later commit, anyone with access to the repo can find them in the history.

If you accidentally commit a secret:

  1. Assume it’s compromised. Rotate the key, password, or token immediately at the issuing service.
  2. Do the work to purge it from history. This is genuinely difficult; see Purging files from history in the Git reference for git filter-repo, and Git history delete for the gitdel script.
  3. Force-push the rewritten history.
  4. Tell any collaborators to re-clone.

The discipline of storing secrets in environment variables or untracked config files, never in code, needs to be a reflex from day one. The safest configuration is to never have the secret in a file that’s in a Git repo in the first place.

A useful pattern for the case where the application genuinely needs a config file: track config.example.json (or .env.example) in the repo with empty or dummy values, and gitignore the real config.json (or .env). New collaborators clone the repo, copy the example to the real name, fill in their own values. The schema of required config is version-controlled; the actual values never are. The example file doubles as documentation: anyone reading the repo can see what config the application expects without ever seeing real credentials.

Large binaries are permanent

Commit a 50MB PDF or a video file once and every clone of the repo carries that blob forever, even if you delete the file in the next commit. Git stores objects by content hash; once an object exists in history, it stays there until you rewrite history to remove it. The deletion commit only records that the working tree no longer contains the file. The blob itself remains in the pack files, downloaded on every clone, forever.

For solo work this looks tolerable for a while. Then the repo hits 2GB, clones take minutes, GitHub starts warning about repo size, and the cost compounds. For writers especially, the temptation is constant: draft PDFs, image assets, audio recordings of dictation, exported ePubs. Each one feels small in isolation. Across two years of work they add up to a repo that’s mostly historical binaries nobody will ever look at.

The default discipline: don’t track binaries in the main repo. Add patterns to .gitignore so the temptation is removed at commit time:

*.pdf
*.docx
*.zip
*.mp4
*.mov
*.mp3
*.wav

If you need to ship a binary as part of a release, attach it to the release on the forge (GitHub Releases, Codeberg Releases). The binary isn’t in the repo; it’s hosted alongside it.

When you genuinely need to version a binary (an image asset that evolves, a Word file an editor requires you to deliver), the standard tool is Git LFS (Large File Storage). LFS replaces the binary in the repo with a small pointer file; the actual binary lives on a separate LFS server and is downloaded only when checked out. The repo stays small and fast even when you’re tracking large evolving assets. Setup is a one-time git lfs install plus a .gitattributes entry:

*.psd filter=lfs diff=lfs merge=lfs -text

Not every forge supports LFS for free at scale (bandwidth quotas apply on GitHub); check before committing to it. For small repos with occasional binaries, plain Git plus a strict .gitignore is sufficient.

If you’ve already committed large binaries you wish you hadn’t, removing them requires history rewriting via git filter-repo, identical to the secrets purge described above. The reference doc covers the procedure. Treat it like a secrets incident: rare, deliberate, all-collaborators-affected. Better to prevent than to clean up.

Signing

To understand signing, start with what a normal commit’s “author” field actually is.

When you commit, Git stamps the commit with the name and email you set in user.name and user.email. Git takes your word for it. There’s no verification. Anyone with a clone of your repo can set their config to your name and your email and make commits that look exactly like yours. Push them to a server and the server has no way to know they didn’t come from you.

For most work this doesn’t matter. The threat model is small: nobody is forging your commits, because there’s nothing to gain. But consider what changes when stakes go up. You’re a contributor to Bitcoin Core. The codebase moves money. An attacker compromises your GitHub account (phishing, credential reuse, anything). They push commits as you. The commits look like they came from you because they have your name and email on them. Maintainers who trust you might merge them. Money moves. By the time anyone realises, the damage is done.

A signed commit closes this gap. When you commit with signing turned on, Git uses a cryptographic key (GPG, or more recently SSH) that lives only on your machine to attach a signature to the commit. The signature is mathematically bound to the commit’s contents and to your private key. Anyone with your public key (which you’ve uploaded to GitHub) can verify two things: that the commit was made by someone who held your private key, and that the commit hasn’t been altered since you signed it. GitHub displays this verification as a green “Verified” badge next to the commit.

Why this matters: even if an attacker takes over your GitHub account, they don’t have your private key (which lives on your laptop, not on GitHub). They can push unsigned commits or commits signed with the wrong key, but they can’t produce signatures that match yours. Reviewers who require signed commits can reject the unsigned ones. The compromise gets caught. The protection is only as good as the key’s: with no passphrase on it, anything running as you can sign as you, so the signature stops people who have never touched your machine and nothing more. Beyond passphrase keys covers the next steps.

This is why Bitcoin Core, the Linux kernel, and most security-critical projects either require or strongly encourage signatures on what reaches the main branch: Bitcoin Core signs every merge commit, and kernel maintainers sign the tags they ask Linus to pull from. The Linux kernel adds another layer: a Signed-off-by: line in the commit message, which is a contributor’s legal attestation that they have the right to submit the change under the project’s licence. That’s a different mechanism from cryptographic signing (it’s just text), but it serves a related purpose of making the chain of provenance explicit.

Notice what those two projects sign. Neither requires every commit to be signed; each signs the point where work is accepted, a merge commit or a tag. That’s enough because of the object model: a tag names its commit by hash, and each commit names its tree and its parent by hash, so one signature at the tip vouches for every commit, tree and file behind it. Signing every commit adds a per-commit badge on the forge, and costs a re-sign after every history rewrite, because a rewrite gives every commit a new hash and the old signatures no longer match: filter-repo removes commit signatures, and a tag’s signature stops verifying.

For solo work, signing every commit isn’t necessary: nobody is trying to impersonate you commit by commit on your hobby blog repo. Signing the versions you publish is different once anyone relies on them. A reader who checks a signed tag against your published key can tell your version from one altered on the forge, a mirror or a fork, without trusting any of them. If nothing you publish is ever checked, skip signing; you’re not missing anything important.

To set it up, use SSH signing if you already have an SSH key for GitHub (GPG is the older alternative), tell Git to sign tags by default, and upload the public key to GitHub as a signing key; from then on every tag you push carries the green badge. Signing every commit is one more setting, for projects that require it. The Git reference has the exact commands.

One pragmatic note: most developers don’t sign, and the ecosystem hasn’t fully standardised. You’ll see plenty of unsigned commits from serious developers in serious projects, because the cost of setting up signing across multiple machines and keeping keys in sync isn’t trivial, and the benefit only kicks in for projects that actually verify signatures. Don’t read the absence of signatures as carelessness; it’s usually just rational triage. Read the presence of signatures as someone taking the supply-chain question seriously, which is increasingly the right disposition for anyone touching widely-used code.

Identity layers and isolation

Git and a forge like GitHub treat three things as independent:

  1. The git author identity. The user.name and user.email stamped on each commit. Set locally. Git takes your word for it.
  2. The forge account. The entity that owns the repo and appears in PR threads, issues, audit logs, and the contribution graph.
  3. The SSH key. What authenticates a push to the forge. Each key is registered to exactly one forge account; the forge rejects the same public key uploaded to two accounts.

These three layers are independent at the protocol level. Nothing prevents you from stamping commits with one email while pushing them with a key registered to a different account; the forge attributes the push based on the key, and the commit metadata is whatever the local config wrote. What the key does and does not bind is a frequent point of confusion: an SSH key authenticates the push to an account, but the forge never checks the commit’s author name or email against the key, so there is no server-side way to lock a key to an identity. The lock is client-side, and it is the includeIf gitdir: directory binding the Git reference describes: when inside the bound directory, the include file sets user.name, user.email, and core.sshCommand together, so the author identity and the key cannot drift apart for repos under that directory. user.useConfigOnly = true then makes a repo outside that directory fail loud rather than guess from whatever identity was lying around in global config.

For a single identity, the setup is to make all three layers describe the same person consistently: configured author identity, key uploaded to your one forge account, and the directory binding selecting them together by location. The Git reference’s Identity setup section has the recipe.

For pseudonymous separation, keeping one identity unlinked from another where deanonymisation would have real cost, config-level separation on one OS account is not enough. The protocol independence above means a single slip at any layer links the identities: an author email set wrong once is in commit history forever (unless you rewrite history); a push that goes out with the other identity’s key lands in the wrong account; a signature publicly binds a commit to a known key; a host compromise reads both ~/.gitconfig and ~/.ssh at once. The convergent pattern among people who actually need pseudonymous separation, used by Tor developers, Bitcoin Core’s pseudonymous contributors, and Qubes users, is OS-level isolation: a separate OS user account, a VM (Whonix on Qubes is the canonical setup), or a separate physical machine. Different home directory, different ~/.gitconfig, different ~/.ssh, different browser, different shell history. The “wrong terminal” class of failures disappears because the boundaries are real, not procedural.

This guide is single-identity on a single OS account by design. The Git reference’s Identity setup section gives the fail-loud directory-bound setup; that’s the right tool for keeping one identity coherent, not for separating two. If you need a pseudonymous identity, run that same setup in a separate OS user account or VM and accept the friction.

Beyond passphrase keys

A passphrase-protected SSH key on disk is the baseline for any serious setup. If your threat model warrants more, three axes go further. None is required; each is the documented next step beyond the previous.

  1. Hardware tokens. YubiKey or Nitrokey. The private key lives on the hardware token and never touches the filesystem. Even a fully compromised laptop cannot extract the key; an attacker can use the key only while the token is physically present and you’ve entered its PIN. Two integrations matter: GPG subkeys stored on the token (the long-standing pattern, used by Bitcoin Core maintainers and Debian developers), and FIDO2 SSH keys (ssh-keygen -t ed25519-sk, simpler, newer, narrower in scope). YubiKey-with-GPG-subkeys is the most widely-documented setup.

  2. Network metadata via Tor. Pushing over SSH leaks the source IP to the destination host (which network is doing the push). Routing SSH through Tor with ProxyCommand or running git operations from inside Whonix protects this metadata. The destination sees an SSH connection from a Tor exit node; the SSH protocol itself is unchanged. Useful when the fact that an identity exists at a given network location is itself sensitive.

  3. Signing as an independent layer. SSH (or GPG) signs tags or commits cryptographically, separate from how the push happens. A signature proves a specific key made it; an attacker who compromises your forge account but not your signing key can push fake commits, but they can’t sign them. Bitcoin Core requires signed merges to master; the Linux kernel uses GPG-signed tags on maintainer trees. For personal projects, signing tags matters once anyone checks what you publish, and signing every commit mainly when a project requires it; Signing has the reasoning.

These are orthogonal. You can use a YubiKey without Tor, Tor without signing, signing without a YubiKey. The natural progression is: passphrase keys (current), then signing (cheap to add, no hardware needed), then hardware tokens (real cost, real benefit), then Tor (situational, depends on what metadata you’re protecting).

What experts actually do depends on what they’re protecting. Bitcoin Core release maintainers sign with GPG subkeys on YubiKeys, work over SSH on regular Internet (release attribution is the threat, not network metadata). Tor developers work over Tor inside Whonix (network metadata is the threat, signing is sometimes a separate concern). For most contributors, a passphrase-protected SSH key plus commit signing (when the project requires it) is the realistic ceiling.

Tooling worth knowing about

You don’t need any of these to start. Knowing they exist means you can reach for them when you’re ready.

  1. git-delta. Replaces less as Git’s pager with syntax-highlighted, optionally side-by-side output. Configure with git config --global core.pager delta. Useful when you’re reading diffs in the terminal; read-only, so for editing while diffing see the vim and VSCodium sections.
  2. tig. Terminal-based Git history viewer. Faster than any GUI once you’re comfortable, and it works over SSH where a GUI can’t. Navigate commits with arrow keys, see diffs inline, stage interactively.
  3. lazygit. Terminal UI for most Git operations. Many long-time command-line users find it more efficient than raw Git or a full GUI. Commit, rebase, cherry-pick, stash, branch management, all with single keystrokes.
  4. gh. The official GitHub CLI. Create PRs, review them, manage issues, clone your own repos by name, all from the terminal. Its manual is at cli.github.com/manual.

These are what experienced users accumulate over years. None is necessary on day one. Don’t install them defensively. Install them when you feel the specific friction they remove.

Slow down before destructive commands

The instinct when a Git command gives you an unexpected state is to run more commands trying to “fix” it. That’s how people actually lose work: not from the original problem, but from frantic recovery attempts.

Stop. Read the output. Understand what state you’re in. Look up what the recovery actually requires. Then proceed deliberately.

Ninety-nine percent of Git “disasters” are recoverable if you don’t panic. A small percentage become unrecoverable because someone force-pushed or hard-reset in a moment of frustration without reading the reflog first.

The commands that actually destroy work, in order of danger:

  1. git push --force (without --force-with-lease). Overwrites remote history unconditionally.
  2. git reset --hard when uncommitted work exists. Throws away the working directory without warning.
  3. git clean -fdx. Deletes untracked files, including ignored ones.
  4. git rebase mid-resolution when confused. Produces states that require careful manual surgery to unwind.

When in doubt, make a backup branch first. git branch backup-before-i-try-this costs nothing and buys you a trivial way to undo whatever you’re about to do.

What Git silently discards

Git’s storage model is lower-level than its surface commands suggest. Several high-level operations look like they’re recording something, but the recording happens by inference at read time, or doesn’t happen at all. The common pattern: the user assumes Git is preserving intent; Git is actually preserving snapshots, and reconstructs intent later by heuristic. Most of the footguns that catch experienced users belong to this category.

Renames

The clearest case. There is no rename operation in Git’s object model. A commit stores trees of blobs: snapshots, not operations. When you run git mv old.js new.js, Git records old.js as deleted and new.js as added, identical to rm old.js && cp something new.js. The rename intent is thrown away at commit time.

This means git log --follow and every rename-aware diff are post-hoc heuristics. Each invocation re-infers the rename from scratch by comparing content similarity across the delete/add pair. There is no stored fact to look up: just a guess from content.

Git’s default similarity threshold for rename detection is 50%. --follow uses that threshold. So --follow traces through a rename fine as long as the file’s similarity score stays above 50%; below that, it loses the thread entirely. The practical implication: keep rename commits content-pure. If you must edit a file at the same time you rename it, do the rename in one commit and the edit in a separate one. A rename-only commit has 100% similarity and is bulletproof for --follow; a rename-plus-substantial-edit commit risks dropping below 50% and silently breaking history traversal for that file forever afterward.

The reference doc has a prepare-commit-msg hook that inspects every commit as it is being prepared, normal or amend, and warns when it contains a rename below 60% similarity (10 points above the danger floor). It looks for renames down to 30% similarity, because at the default 50% the worst cases appear only as a delete plus an add. See One-time setup there.

This is a known and longstanding criticism of Git’s design: renames would need to be first-class objects in the commit format to record intent reliably, not inferred from diffs. The burden falls on the user to keep renames content-pure so the heuristic can recover what the tool discarded.

Other things in the same category

Several other footguns are variants of the same pattern: Git’s model is lower-level than users expect, so what feels like a high-level operation either discards information at commit time or relies on inference that can fail silently.

  1. Amending a pushed commit rewrites history for everyone else. --amend doesn’t alter the previous commit; it replaces it entirely with a new one that has a different hash. If the original was already pushed, anyone who pulled it now has a diverged history. The rule: only amend commits that haven’t been pushed.

  2. git add . and git commit -a stage everything indiscriminately. Easy to commit debug code, credentials, build artifacts, or unrelated changes by accident. The discipline is git add <file> explicitly, and reviewing git diff --staged before every commit. The staging area was designed to give you control over what’s in the next commit; bypassing it gives up the safety it provides. It matters most while you’re finishing a conflict: git add is what tells Git a conflict is resolved, and Git never looks inside the file, so a file still full of conflict markers counts as resolved once it’s staged, and git commit -a in the middle of a merge commits the markers along with every other edit in the tree.

  3. Merge vs rebase is a permanent, often-invisible choice about what history looks like. A merge commit preserves topology: you can see that work happened in parallel. A rebase rewrites commits as if they were always linear, discarding that topology. Most users pick one out of habit without realising the choice is irreversible once pushed.

  4. .gitignore doesn’t untrack files already tracked. The ignore patterns only filter the untracked set. If you committed a file and then added it to .gitignore, Git keeps tracking it. You have to git rm --cached <file> to stop tracking; and if the file contained credentials, the secret is still in history unless you also rewrite history (see Purging files from history in the reference).

  5. git reset --hard and git clean -fd are not symmetric with git stash. Reset hard and clean destroy uncommitted working-tree changes permanently. The reflog saves committed history but cannot recover what was never committed. The default assumption that “Git always has a recovery path” only holds for committed work.

  6. Submodules are not automatically updated on pull. A repo with submodules cloned without --recurse-submodules has empty submodule directories until you run git submodule update --init --recursive. People spend hours debugging missing files before discovering this.

The common thread is the same one the rename case makes vivid. Git stores snapshots and hashes, not intentions. Every high-level operation that looks like it’s recording semantics (rename, track, ignore, amend) either discards the semantics at commit time or infers them after the fact. Knowing this in advance prevents the class of mistake that comes from assuming the tool is doing more than it actually is.

Three things to keep in mind

Three framings worth holding across years of use. Not commands, not mechanics. Principles about how to learn Git, what you get out of using it, and how to evaluate advice about it.

Mental model before commands

Start by internalising the object model (blobs, trees, commits, refs) and the branches-as-pointers idea. Once those are solid, specific commands become obvious in shape: what they do, why the flags exist, what they cost. The reverse path, memorising commands first and hoping the model assembles itself from fragments, takes longer and produces worse intuition. It also makes recovery from unfamiliar states harder, because you’re pattern-matching to commands rather than reasoning about what’s actually in the repo. Everything in this document is structured around that priority: model first, mechanics second.

Commit messages are a prose practice

Every commit message forces a small compression: what changed, why, for whom. Over years the practice sharpens your prose in general, because compressing a thought into a summary line is exactly the skill long-form prose depends on. Writers who version their work get this benefit twice: the prose they commit improves, and the messages about the prose improve too. Treat the summary line the way a copy editor treats a headline: one line, specific, load-bearing. Treat the body the way you treat a good paragraph: context first, reasoning second, caveats last. This is work you were going to do anyway as a writer. Git just gives it a daily occasion.

AI advice on Git has a known asymmetry

AI answers about Git are usually smooth, syntactically correct, sometimes elegant, and can still miss what a practitioner who has lived with a particular failure mode for five years knows. The gap is widest on judgment calls (when to rebase vs merge, when to sign commits, when to force-push, when to rewrite history) and on edge cases that only surface after thousands of hours of real use. Weight AI advice accordingly: fine for mechanics and syntax, less reliable for “should I.” For judgment calls and high-stakes operations, cross-check against practitioner sources: Pro Git, kernel mailing list threads, Bitcoin Core maintainer posts, Tim Pope’s essay, the man page. The AI gives you a plausible answer quickly; practitioner sources give you the answer that survived contact with real projects over real time. Both are useful. Don’t confuse them.

Where Git came from

Git’s design makes more sense once you know what it was reacting against.

Version control before Git was overwhelmingly centralised: one authoritative copy of the history lived on a server, and your working copy was a client of it. The lineage ran through RCS, then CVS, then Subversion.

RCS (Revision Control System, Walter Tichy, around 1982) versioned one file at a time on a single machine, locking a file so only one person could edit it at once. CVS (Concurrent Versions System) grew out of RCS: Dick Grune’s 1986 shell scripts, rewritten in C by Brian Berliner around 1989. CVS added a network server and concurrent editing, and it became the de facto standard for open-source projects through the 1990s. But it inherited RCS’s core limitation: it tracked each file’s revisions separately, with no notion of a project-wide snapshot, no atomic commit (an interrupted commit could leave the repository half-written), and no real way to follow a file across a rename.

Subversion (CollabNet, begun 2000, 1.0 in February 2004) was built explicitly as “CVS done right.” It fixed the worst of it: commits became atomic and repository-wide, with a single revision number advancing across the whole tree. But it stayed centralised: the server still held the only complete history, every commit needed the network, and branching and merging remained heavyweight. At the moment Git appeared, Subversion was the rising standard and CVS the fading one.

The immediate trigger was something else. The Linux kernel had, from 2002, been using BitKeeper, a proprietary distributed system offered to kernel developers free of charge. Distributed meant each developer held a full copy of the history rather than renting access to a central one, and the kernel community valued that enough to depend on a closed tool. In April 2005 the arrangement collapsed: after Andrew Tridgell wrote a tool to interoperate with BitKeeper’s protocol, BitMover treated it as a breach of the gratis licence and withdrew free access. The kernel was left without version control it was willing to use.

Linus Torvalds wrote the first version of Git that same month, April 2005, and had it self-hosting within days. The design was a direct response to the whole lineage above. Distributed, because BitKeeper had shown the value and the central-server model had shown the cost: every clone is a full, independent copy of the history (developed in Remotes and the distributed model). Snapshot-based and atomic, because CVS’s per-file, non-atomic model was the thing to escape: a commit records the entire tree at once (developed in How commits actually work). Content-addressed and cryptographically chained, because trust across thousands of contributors required history to be tamper-evident rather than merely stored (developed in the object-model sections, and in the Torvalds talk under Further reading). Fast at branching and merging, because a kernel-sized project lives or dies by how cheaply work can diverge and rejoin.

Much of what the rest of this document treats as fundamental was, originally, a deliberate correction of something a prior system did badly.

Closing: deliberate practice

Git rewards deliberate practice and punishes casual use. The people whose repositories you admire (Torvalds, the Bitcoin Core maintainers, whoever) didn’t arrive at their practices by being naturally gifted. They arrived by making mistakes early, thinking carefully about what went wrong, adjusting their habits, and being consistent over years.

Every piece of advice in this document is really a variant of “take it seriously, even when the stakes feel low, because the habits you build on small projects are the habits you’ll have on consequential ones.”

You won’t look back in twenty years and thank anyone for any specific command. You’ll thank them, if they’ve done their job, for conveying that Git is a craft; that the craft is mostly about clarity and discipline rather than cleverness; and that the small investments in doing it well early are what make the later years pleasant rather than painful.

Consistency over cleverness, across time, is the whole game.

Honest caveats on best practice

Some edges of “best practice” in Git are contested. The pull-rebase-versus-merge debate. The squash-versus-preserve-history debate. The when-to-rewrite-history debate. Reasonable, experienced people disagree.

Don’t take any of this as dogma. Take it as a strong starting position that will serve you well, and adjust over time as you develop your own views through experience. The framework matters more than any specific rule within it.

A floor, not a ceiling

This document is deliberately not comprehensive. It covers the object model and the habits that pay off over years, and it stops there, because the goal was never to memorise every command but to push you toward seeing what Git actually is underneath. Everything here rests on one fact: a repository is a small content-addressed database of blobs, trees, and commits, and the everyday commands are a convenience layer over reading and writing those objects. Once that graph is something you can hold in your head, the commands that used to feel dangerous stop being dangerous, because you are reasoning about what is actually stored rather than pattern-matching to remembered incantations. That fluency is what makes someone the person a team runs to when history gets tangled. The single best hour you can spend toward it is Tim Berglund’s “Git From the Bits Up” (in Further reading below), which takes Git apart to the raw objects and rebuilds a commit by hand from plumbing alone. Watch it, then do it yourself once; the day it clicks is the day Git stops being a pile of commands and becomes a database you happen to drive from a command line.

Further reading

  1. Pro Git by Scott Chacon and Ben Straub, free at git-scm.com/book. Read it once in your first year and once again after a few years of real use. Different sections will matter at different stages. For the object model specifically, the “Git Internals” chapter walks through building a commit from git hash-object, git write-tree, and git commit-tree by hand.
  2. “A Note About Git Commit Messages” by Tim Pope (2008). Short. Internalise and move on.
  3. git help <command>. The manual pages are dense but accurate. The ability to read them directly, rather than relying on Stack Overflow answers of uncertain quality, is a long-term advantage.
  4. “Git for Ages 4 and Up” by Michael Schwern (linux.conf.au 2013; recording). Schwern builds a Git repository out of children’s construction toys while running the equivalent commands, so the talk shows what is actually happening inside Git rather than teaching the command surface. Blobs, trees, commits, branches, the staging area, remotes, and rebase all become physical objects you can see. The gentlest on-ramp to the object model; watch it first if the graph has never clicked.
  5. “Version Control (Git)” by MIT’s The Missing Semester of Your CS Education (Lecture 6, 2020; ~1h25m; recording; notes and exercises). The lecture deliberately teaches Git’s data model before the command interface, a repository as a content-addressed store of blobs, trees, and commits forming a DAG, and then derives the everyday commands from it. The fullest free lecture in the data-model-first tradition; watch it once you want the whole model laid out end to end.
  6. “Tech Talk: Linus Torvalds on git” (Google Tech Talk, 2007; recording). Torvalds, who wrote Git, explains at Google why it is built the way it is: distributed rather than centralised, content-addressed and cryptographically chained so history is tamper-evident, and optimised for merging and trust across a large contributor base. Not a tutorial but the design-rationale talk that explains the why behind the model the other talks teach; also a candid window into the priorities that shaped the tool.
  7. “Git From the Bits Up” by Tim Berglund (JAXConf, 2013; ~55 min; recording). An advanced talk, explicitly not for beginners, that takes Git apart to its raw objects and rebuilds a commit by hand using low-level plumbing. The canonical “see the object graph” talk; watch it once you are comfortable with the daily workflow.
  8. “git: not just for source code anymore” by Josh Triplett (linux.conf.au, January 2013; recording). Triplett, a longtime Linux kernel and Debian developer, makes the case that Git is a general-purpose, tamper-evident versioning engine that just happens to be famous for code: the same blobs, trees, and commits version configs, server state, datasets, and entire wikis. Worth watching once to see how much of “Git is for code” was convention rather than design.

Start now. Be consistent. The value compounds.

Git reference

  • For: anyone who knows what they want Git to do and needs the command.
  • Before you start: Getting started with Git.
  • Reading time: about 48 minutes end to end, but it’s a lookup: find your task and read that section.
  • You end with: Git set up once, and the command for whatever you need to do next.

Lookup by task. The concepts doc covers the mental model and the why; this one tells you the command.

Conventions in this doc:

  1. Commands shown with bash tags.
  2. Angle-bracket placeholders like <file> or <sha> mean substitute your own value.
  3. HEAD~N means “N commits before the current one.” HEAD~1 is the previous commit.
  4. Anything marked “local only” rewrites history; never run on commits that have been pushed to a shared remote.

One-time setup

Set your identity. Stamped on every commit; run once per machine.

git config --global user.name "Your Name"
git config --global user.email "you@example.com"

Recommended: don’t set a global identity. Turn on fail-loud and bind the identity to a directory instead, so a repo created outside that directory errors rather than committing under whatever was lying around in global config. See Identity setup.

Set the default branch name to main so new repos use it.

git config --global init.defaultBranch main

Set the editor Git opens for commit messages and interactive rebase.

git config --global core.editor "codium --wait"

Set vim as the diff tool for git difftool. Skips the per-file confirmation prompt. Use this if you read diffs in vim (with or without vim-fugitive); see Which tool when in the concepts doc for how this fits among the other diff-viewing options.

git config --global diff.tool vimdiff
git config --global difftool.prompt false

Make git pull rebase instead of merge. Keeps history linear; no noise commits. With uncommitted changes in the tree, a rebasing pull refuses to start (error: cannot pull with rebase: You have unstaged changes., or Your index contains uncommitted changes. when they’re staged); commit or stash them first. If a pull stops on a conflict, see If a pull stops on a conflict.

git config --global pull.rebase true

Push new branches without the -u dance. With this set, the first push of a new branch creates the remote branch and sets the upstream automatically (Git 2.37+); without it, Git refuses until you run git push -u origin <branch> once.

git config --global push.autoSetupRemote true

Prune deleted remote branches on every fetch. Without it, branches deleted on the remote linger in git branch -a output indefinitely.

git config --global fetch.prune true

Enable rerere (reuse recorded resolution). Git remembers how you resolved a merge conflict and applies the same resolution if the conflict recurs.

git config --global rerere.enabled true

Make git commit show the staged diff in the editor (below a scissors line; the diff gets stripped on save). Reading the diff while writing the message is what most experienced users do reflexively. Available since Git 2.9.

git config --global commit.verbose true

Point Git at a commit message template. The template pre-fills the editor with reminders of the 50/72/imperative rules as comment lines (which get stripped on save).

git config --global commit.template ~/.gitmessage

Create the template file:

cat > ~/.gitmessage <<'EOF'


# Subject (line 1): imperative, under 50 chars, no period.
# Blank line between subject and body.
# Body (line 3+): wrapped at 72. Explain why, not what.
# The diff below the scissors line shows what.
EOF

The two leading blank lines are intentional: that’s where the cursor lands when the editor opens.

Codium settings for commit-message editing. Open Settings, click the JSON icon top-right, merge into settings.json:

"[git-commit]": {
    "editor.rulers": [50, 72],
    "editor.wordWrap": "wordWrapColumn",
    "editor.wordWrapColumn": 72
}

Vertical rulers at columns 50 and 72; word wrap activates at column 72.

Install a commit hook with two checks. The first refuses a commit that adds conflict-marker lines: git add is what tells Git a conflict is resolved, and Git never looks inside the file, so markers left in it are committed like any other text. The second is a rename-safety warning. Git records renames as delete+add and re-infers them post-hoc when you run git log --follow; that inference is content-similarity based, and --follow loses the thread when the rename commit’s similarity drops below Git’s 50% default. The hook inspects the commit being prepared, normal or amend, and warns when it contains a rename below the 60% safety margin (10 points above the floor). See the concepts doc, What Git silently discards, for the fuller framing.

mkdir -p ~/.git-hooks
cat > ~/.git-hooks/prepare-commit-msg << 'EOF'
#!/bin/sh
# Git runs this hook on git commit and on git rebase --continue
# (pre-commit does not run there); --no-verify does not skip it.
#
# 1. Refuse a commit that adds conflict-marker lines. git add is what marks a
# conflict resolved; Git never looks inside the file. Only the opening and
# closing markers count, because a bare ======= line is also a Markdown
# heading underline.
if git diff --cached --no-color --no-ext-diff --no-textconv -U0 |
    grep -qE '^\+(<<<<<<<|>>>>>>>)( |$)'; then
  echo "Conflict markers are staged. Fix the file, git add it, and retry." >&2
  echo "To commit markers on purpose: git -c core.hooksPath=/dev/null commit ..." >&2
  exit 1
fi
#
# 2. Warn when the commit being prepared contains a rename with substantial
# content changes (similarity <60%). git log --follow loses a file across a
# rename below 50%; 60% leaves a margin. Detection runs at 30%, because at
# Git's default 50% the worst cases show as a delete plus an add and are
# never seen. $2 is the message source; $3 is HEAD when the source is an
# amend.
if [ "$2" = "commit" ] && [ "$3" = "HEAD" ]; then
  base=HEAD~1   # amend: the new commit's parent is HEAD's parent
else
  base=HEAD     # normal commit: the new commit's parent is HEAD
fi
git rev-parse --verify --quiet "$base" >/dev/null || exit 0   # initial commit: nothing to compare
if git diff --cached --find-renames=30% --name-status "$base" | grep '^R' | awk '{score=substr($1,2)+0; if(score<60) found=1} END{exit !found}'; then
  echo "Warning: this commit contains a rename with substantial content changes (similarity <60%). git log --follow may lose history. Rename in one commit; edit in a separate commit." >&2
fi
exit 0
EOF
chmod +x ~/.git-hooks/prepare-commit-msg

Point Git at that hooks directory globally:

git config --global core.hooksPath ~/.git-hooks

The marker check blocks. It runs where conflicts get finished: on git commit, including the commit that concludes a merge, and on git rebase --continue, where a pre-commit hook would not run; --no-verify doesn’t skip it. It looks only at lines the commit adds, and only at <<<<<<< and >>>>>>> lines, because a bare ======= is also a Markdown heading underline. When it blocks, it prints its two lines and the commit doesn’t happen; during a rebase Git adds error: could not commit staged changes. and stays stopped. To commit a page that shows conflict markers on purpose, like this one, bypass hooks for that one commit with git -c core.hooksPath=/dev/null commit.

The rename check compares the staged diff against the commit’s actual future parent: HEAD for a normal commit, HEAD~1 for an amend, whose parent is the old commit’s parent and whose content the index already holds at hook time. It covers both the fresh rename-plus-edit commit and the pure-rename commit about to receive edits via amend, skips cleanly on a repo’s first commit, and only warns. It looks for renames down to 30% similarity: at Git’s default 50% it would miss the very renames --follow already loses, and at 20% a deleted post and an unrelated new one that shared front matter were taken for a rename.

Useful aliases. Add to ~/.gitconfig under [alias], or set with git config --global alias.<name> '<value>'. The status, staging, diff, log, branch, rebase, stash and push entries use names and commands found across the common published sets (Pro Git, Atlassian, mwhite’s gist, Haacked’s post, and oh-my-zsh’s git plugin, which spells them with a g prefix). The commit family is this setup’s own, and its a means add -A, where ca in mwhite’s gist and gca in oh-my-zsh mean commit -a. Grouped by purpose; each line is what gets typed, and the comment is what it does.

[alias]
    # Status
    s   = status                              # full status
    st  = status -s                           # short, one line per file

    # Stage
    a   = add
    ap  = add -p                              # interactive hunks (atomic commits)

    # Guard: ca, caa, caam and rv run this first; note 9
    idle    = "!f() { for s in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do if [ -e \"$(git rev-parse --git-path \"$s\")\" ]; then echo \"git idle: a merge, rebase, cherry-pick or revert is in progress; finish or abort it first (git status shows how)\" >&2; return 1; fi; done; if [ -n \"$(git ls-files -u)\" ]; then echo \"git idle: unresolved conflicts; fix each file and git add it first\" >&2; return 1; fi; }; f"
                                              # refuse mid-merge, mid-rebase or with unresolved conflicts

    # Commit
    c       = commit -m                       # one-line message
    ca      = !git idle && git add -A && git commit -m
                                              # stage everything + one-line message
    caa     = !git idle && git add -A && git commit --amend --no-edit
                                              # stage everything + fold into prev commit, keep message
    caas    = commit --amend --no-edit        # fold ALREADY-STAGED into prev commit, keep message
    caam    = "!f() { git idle && git add -A && git commit --amend -m \"$1\"; }; f"
                                              # stage everything + fold into prev commit, NEW message
    caams   = commit --amend -m               # fold ALREADY-STAGED into prev commit, NEW message
    cm      = "!bash -c 'args=(); for m in \"$@\"; do args+=(-m \"$m\"); done; git commit \"${args[@]}\"' --"
                                              # each arg becomes a paragraph; no args opens the editor; note 8
    cf      = commit --fixup                  # mark fixup; pair with ria
    cs      = commit -s                       # signoff (DCO projects)

    # Revise older commits (git-revise)
    rv      = !git idle && git revise         # fold staged changes into, or reword, an older commit; note 10

    # Diff
    d   = diff
    dc  = diff --cached                       # the about-to-commit diff
    ds  = diff --stat                         # filenames + line counts
    dw  = diff --word-diff                    # word-level (good for prose)

    # Log
    lg  = log --oneline --graph --all --decorate    # standard pretty log
    ll  = log --oneline                              # quick scan, no graph
    lf  = log --follow --                            # file history, follows renames

    # Inspect
    last     = show --compact-summary HEAD          # what you just committed
    when     = log -1 --format=%cr --               # last touch on a file (relative)
    lastfile = log -1 --format='%cs %s' --          # last touch (date + subject)
    who      = shortlog -sne                        # contributors with counts
    aliases  = config --get-regexp '^alias\\.'      # list your own aliases

    # Branch / switch
    br  = branch
    co  = checkout
    cob = checkout -b
    sw  = switch                              # modern checkout (branches only)
    swc = switch -c                           # modern checkout -b

    # Restore
    unstage = restore --staged
    discard = restore                         # discard working-tree edits to file

    # Reset (commit rewriting)
    uncommit = reset --soft HEAD~1            # undo last commit, keep changes staged
    squash   = reset --soft                   # use as: git squash HEAD~3, then git commit
    # Rebase
    ria = rebase -i --autosquash              # auto-orders --fixup commits
    rc  = rebase --continue
    ra  = rebase --abort

    # Stash
    sl  = stash list
    sa  = stash apply
    sp  = stash pop

    # Push
    pf  = push --force-with-lease --force-if-includes    # the safe force push

Reliability notes on the alias set above.

  1. pf uses --force-with-lease --force-if-includes, not plain --force. The lease form refuses to push if the remote has new commits since your last fetch; --force-if-includes (Git 2.30+) additionally verifies your local history includes everything from the remote ref before allowing the push. Together they prevent silent overwrites of someone else’s work. Never alias plain --force to a short name.
  2. There are four amend-related aliases (caa, caas, caam, caams) and a deliberate one omitted (commit -a --amend). caas and caams operate on already-staged changes; you control what gets folded in, with caams also taking a new message. caa and caam both run git add -A first, which stages new files in addition to modified ones. commit -a --amend only stages modified tracked files, not new ones, which can lead to commits that omit the file you thought you were including. If you want one-step “stage everything and amend,” prefer caa/caam over commit -a --amend. All three of these rewrite the previous commit, so the local-only rule applies: don’t run on pushed commits without a force-with-lease push afterwards. caa and caam, like ca, run the idle guard first (note 9).
  3. There is deliberately no alias for reset --hard or clean -fdx. The typing friction is the safety. Reflog can recover lost commits but cannot recover uncommitted working-tree changes a hard reset destroys.
  4. sw / swc (Git 2.23+) and restore separate what checkout did into two cleaner commands. They refuse some operations that checkout would silently complete, which is desirable. The older co / cob are kept for muscle memory; both forms work indefinitely.
  5. The cf + ria pair is the standard atomic-commit fixup workflow used on Bitcoin Core and other projects with rebase-based PR review. git cf <sha> records a fixup against an earlier commit; git ria <base> runs interactive rebase with autosquash, which automatically orders and squashes the fixups into their targets. rv (note 10) does the same fold in one command when there’s no conflict.
  6. Shell-form aliases (the ones starting with !) are idle, ca, caa, caam, cm, rv, and whoami. The leading ! makes git run a shell command rather than a git subcommand. Shell aliases execute from the repo root, not the directory where git was invoked. For the ones that call git add -A (ca, caa, caam) that changes nothing, because git add -A always operates from the repo root regardless of where you invoke from, which is what you want for snapshot-style commits but worth knowing if you ever expected scoped staging.
  7. The aliases entry uses '^alias\\.' with a doubled backslash. That’s gitconfig escaping: the value is parsed before being passed to the regexp engine, so \\ becomes \ and the pattern matches a literal dot. If you copy this pattern outside gitconfig (into a shell script, for instance), unescape back to a single backslash. Relatedly, a value containing ; or # must be wrapped in double quotes in gitconfig, since both characters otherwise begin a comment; that is why idle, caam, and cm are double-quoted above.
  8. cm passes each argument as its own -m, so git cm "subject" "first body para" "second body para" expands to git commit -m "subject" -m "first body para" -m "second body para"; with no arguments it opens the editor like plain git commit. A plain alias can’t interleave -m between your arguments, since simple aliases just append args verbatim, so this one is a shell alias with a loop. The explicit bash -c is necessary because on Devuan /bin/sh is dash, which has no arrays; the ! prefix would otherwise hand the shell command to dash and break. The trailing -- sets $0 inside the bash -c script so that git’s appended arguments land in $1 onward and $@ stays clean; without it, the first argument would be consumed as the script name and dropped. Edit .gitconfig directly rather than using git config --global alias.cm '...' for this one: the layered shell quoting on the command line is error-prone and the file edit is cleaner.
  9. idle is a guard rather than a command you need to type. ca, caa, caam and rv run it first, and it stops them with a one-line message while a merge, rebase, cherry-pick or revert is in progress, or while any file still has unresolved conflicts, which also covers a conflicted git stash pop. In those states git add -A would stage conflict markers as if they were resolved, and an amend during a stopped rebase lands on the commit being replayed onto, not on yours. Use plain git there instead (git add <file>, then git commit, git rc or git cherry-pick --continue), as git status says. The two messages are git idle: a merge, rebase, cherry-pick or revert is in progress; finish or abort it first (git status shows how) and git idle: unresolved conflicts; fix each file and git add it first. On its own, git idle prints nothing and exits 0 when nothing is in progress. One cost: caa refuses at a rebase edit stop too; use git add -A && git caas there.
  10. rv runs git revise after the idle guard, which matters because git-revise itself doesn’t check for a rebase in progress. It needs git-revise installed (sudo apt install git-revise); usage is under Revising older commits.

To type g instead of git, add a shell alias to ~/.bashrc:

alias g=git

Then source ~/.bashrc or open a new terminal. g cm 'subject' 'body' will work because g expands to git before the shell invokes it, so git sees cm as a subcommand and looks it up in your .gitconfig aliases normally.

Check what’s configured.

git config --list
git config --list --show-origin   # values + which file each came from
git config --global --edit        # opens the global config file directly
git aliases                       # list just your aliases (after adding the alias above)

git config --list --show-origin is the unified view: every effective setting and the file it came from (system, global, per-repo, includeIf chains). Run inside a repo to confirm the identity is resolving correctly; run outside any identity directory to see the fail-loud state (no user.email set anywhere).

Show Git’s state in your shell prompt, so a half-finished merge or rebase is visible before you push or commit on top of it. Add this to the end of ~/.bashrc; it assumes your prompt ends in $ , as Devuan’s default does:

# git branch and in-progress state (MERGING, REBASE) in the prompt
if [ -r /usr/lib/git-core/git-sh-prompt ]; then
    . /usr/lib/git-core/git-sh-prompt
    PS1=${PS1%'\$ '}'$(__git_ps1 " (%s)")\$ '
fi

The helper ships with the git package. Expected: no output. In a new terminal, inside a repo, the prompt ends (main)$; during a stopped merge it ends (main|MERGING)$, and during a stopped rebase (main|REBASE 1/1)$, until you finish or abort.

Dotfiles repo: the convention experts use

The commands above get you set up on one machine. The convention for multi-machine setup, used widely across the Linux and privacy-focused dev world, is a dotfiles repo: a private Git repository containing your ~/.gitconfig, your ~/.gitmessage (and often ~/.bashrc, ~/.vimrc, anything else you customise). On a fresh machine you clone the dotfiles repo, put the files in place, and you’re done.

Two tools make the dotfiles repo pattern smoother but neither is required:

  1. GNU Stow (sudo apt install stow) symlinks files from the dotfiles repo into ~ so the repo stays the source of truth. Lightweight, no templating, Devuan/Debian native. The most common pick.
  2. chezmoi is the heavier alternative with templating (different machines can render the same dotfile differently) and built-in age encryption for secrets. Worth it once you’re managing several machines or want secrets in the repo without plaintext exposure.

If the dotfiles repo is private and on encrypted storage, plain files are fine. If it’s pushed to GitHub or similar, encrypt anything you’d rather not publish with git-crypt or chezmoi’s built-in age support.

Pseudonymous work

This setup is single-identity by design. For a pseudonymous identity (separate from your real identity), use a separate OS user account, a VM (Whonix on Qubes is the canonical setup), or a separate physical machine. Repeat this setup there with that identity’s name and email.

The reason is structural. Config-level identity separation on one OS account leaves several footguns intact: a repo placed under the wrong directory, a remote pointed at the wrong account, the SSH agent offering the wrong key, a host compromise that reads both identities at once. The convergent pattern among Tor developers, Bitcoin Core’s pseudonymous contributors, and Qubes users is OS-level isolation: different home directory, different ~/.gitconfig, different ~/.ssh, different browser, different shell history. The “wrong terminal” class of failures disappears because the boundaries are real, not procedural.

See the concepts doc, Identity layers and isolation, for the threat-model reasoning, and Beyond passphrase keys for the optional next tiers (hardware tokens, Tor transport, independent signing).

Starting a repo

Initialise a new repo in the current folder.

git init

Clone an existing remote repo.

git clone <url>
git clone <url> <folder-name>   # clone into a specific folder

The daily cycle

Check what has changed and what’s staged.

git status
git status -s   # short output, one line per file

Stage specific files for the next commit.

git add <file>
git add <file1> <file2>

Stage everything that has changed, including new files.

git add -A

Stage only changed tracked files (ignores new untracked files).

git add -u

Stage hunks interactively. Walks you through each change and asks y/n. Use s to split a hunk, e to hand-edit line-by-line.

git add -p <file>

Commit staged changes with an inline one-line message.

git commit -m "short summary of the change"

Commit and open the editor for a longer message (summary line, blank line, body).

git commit

Skip staging and commit all tracked changes in one step. Does not include new untracked files.

git commit -a -m "message"

Stage and commit in one interactive hunk-by-hunk pass.

git commit -p

Commit just one path (or a few) out of many changed, leaving every other change uncommitted. Useful for splitting one file’s distinct rationale into its own commit while the rest stays bundled. Takes the working-tree content of the named paths and does not touch the index for anything else, so you need not stage first.

git commit -m "Drop rebase glossary entry: duplicates concepts doc" -- glossary.md

Everyday workflow

Six commands cover most days, assuming the aliases from one-time setup are in place. The principles behind these (commits as communication, atomic commits, message discipline) are in the Git concepts doc.

Stage everything that changed and commit with a one-line message:

git ca "fix login redirect"

Commit already-staged changes with a one-line message:

git c "fix login redirect"

See what you just committed (metadata, message, and compact summary of changed files):

git last

Stage everything and fold it into the previous commit, keeping the previous commit’s message. Use this when you commit and immediately notice you missed something (a file, a typo fix, a forgotten change in the same logical unit):

git caa

Fold already-staged changes into the previous commit, keeping the previous commit’s message. Use this when you staged precisely what you want and don’t want add -A picking up unrelated working-tree changes:

git caas

Stage everything and fold it into the previous commit, but with a new message. Use this when you want to also change the previous commit’s title or message:

git caam "new commit title"

caa, caas, and caam all rewrite the previous commit (new hash). Safe locally; on already-pushed commits you’ll need git pf afterwards.

When the everyday six aren’t enough.

For a multi-paragraph commit from the command line, subject plus one or more body paragraphs, pass -m multiple times. Git concatenates the values as separate paragraphs separated by blank lines. Documented git behaviour, no alias needed.

git commit -m "subject" -m "body paragraph"
git commit -m "subject" -m "first paragraph" -m "second paragraph"

The same form scopes a body note to one file in a commit that touches several: prefix the body line with the file’s path.

git commit -m "2026-06-03 — intro, glossary, bibliography" \
           -m "glossary.md: dropped the rebase entry; it duplicated the concepts doc and would drift."

git log -- glossary.md surfaces that line against the file later.

Combined with staging:

git add -A && git commit -m "subject" -m "body paragraph"

To start a message with -m and finish in the editor (useful when you’ve typed the subject and want the editor for the body, with rulers and diff visible):

git commit -m "subject" -e

For a fully substantive message, anything where you want auto-wrap at 72, the diff visible while writing, and no shell quoting hassles, drop the -m entirely and let the editor open:

git add -A
git commit

The template pre-fills the editor with rule reminders, the codium rulers mark columns 50 and 72, and the staged diff is appended below a scissors line so you can read the changes while writing the message. Save and close the tab to commit.

To fix the previous commit’s message or fold forgotten changes into it:

git commit --amend          # opens editor with previous message preloaded
git commit --amend --no-edit # keeps message, folds in newly staged changes (long form of caas)

To fold staged changes into a commit older than HEAD, use the fixup + autosquash pair. Local only, rewrites every commit from the target forward.

git add <files>
git cf <hash>          # records "fixup! <subject>" against <hash>
git ria <hash>^        # interactive rebase, autosquash slots the fixup into place

For the common case of folding changes into the commit before HEAD (e.g., you committed something, then committed something else, then realised the earlier commit was incomplete):

git add <files>
git cf HEAD~1
git ria HEAD~3

Why HEAD~3 and not HEAD~2: the fixup commit itself moves HEAD, so the target that was HEAD~1 is HEAD~2 by rebase time, and the rebase base must be the target’s parent, one further back. The sha-based general form above is immune to this shift, because a hash doesn’t move when HEAD does. The rebase opens the editor with the fixup pre-positioned next to its target and pre-marked as a fixup. Save and close to apply. --amend itself only operates on HEAD; there’s no syntax to point it at an arbitrary commit.

git rv does the same fold in one command, and also rewords an older commit; see Revising older commits. The plain pair above is the fallback when it hits a conflict.

The aliases used above (c, ca, caa, caas, caam, caams, last, cf, ria, rv) are defined in one-time setup. The fallback editor flow uses commit.verbose, commit.template, and the codium settings, all also in one-time setup.

Note on shell quoting: every form that takes a message in quotes (the aliases and all -m variants) is subject to shell rules. Single and double quotes are interchangeable for plain strings: the shell strips both and passes the same bytes to git. Single quotes suppress $VAR and backtick expansion; double quotes allow it. Apostrophes, parens, $, !, and * need escaping or careful quoting. If a message would need heavy escaping, use editor mode instead, or pass it on standard input with a quoted heredoc, which Git takes verbatim:

git commit -F - <<'EOF'
Subject line

Body paragraph. Apostrophes, $dollars, `backticks` and !bangs stay as typed.
EOF

Revising older commits

git rv (note 10 under one-time setup) runs git-revise, which folds staged changes into an older commit, or rewrites an older commit’s message, in one command and in memory: the working tree and index are left alone, and a run that fails changes nothing. It replaces the cf + ria pair from Everyday workflow whenever there’s no conflict; when there is one, use that pair. Local only, like every rewrite: the target and every commit after it get new hashes.

Install it once:

sudo apt install git-revise

Expected: apt’s usual install output. Afterwards git revise --version prints a version number.

Fold staged changes into an older commit:

git add <files>
git rv <sha>                  # or HEAD~2; no hash shift to worry about

Expected: Applying staged changes to '<sha>', one line per rewritten commit, then an Updating ... line with the old and new tip. git-revise uses only what’s staged. Its own -a stages changed tracked files but not new ones, so for new files run git add -A first.

Reword an older commit, inline or in the editor:

git rv -m "New subject" <sha>
git rv -m "New subject" -m "Body paragraph" <sha>     # each -m is a paragraph
git rv -e <sha>                                        # editor, with the old message

Fold one existing commit into an earlier one: mark the source as a fixup of the target, then let git-revise squash it in.

git rv -m "fixup! <target subject>" <source-sha>
git rv --autosquash <target-sha>^

If a later commit changed the same lines, git-revise stops and asks Edit conflicted file? (Y/n). Answer n: it prints merge conflict: user aborted and nothing changes, your staged changes included. Then do that fold with plain git, which gives you Git’s usual conflict tools and runs your hooks:

git cf <sha>
git ria <sha>^

Answering y instead opens the conflicted file in your editor and then asks Merge successful? (y/N). If markers are left it first warns conflict markers found in the merged file, and the default, N, aborts without changing anything.

Three things git-revise doesn’t do:

  1. Run your hooks. Neither the conflict-marker check nor the rename warning sees what it writes.
  2. Check for a merge or rebase in progress. Run after git add -A at a stopped rebase, it rewrites the commit being replayed onto, conflict markers included; rv runs the idle guard first for that reason.
  3. Sign commits with SSH before version 0.8.0. With this setup’s tags-only signing that doesn’t matter. If you turn commit signing on and git revise --version prints something older, every rewrite fails with gpg: signing failed: No secret key.

Remotes

List remotes.

git remote -v

Add a remote.

git remote add origin <url>

Change a remote URL.

git remote set-url origin <new-url>

Remove a remote.

git remote remove origin

Fetch from remote without merging.

git fetch origin

Fetch and clean up references to branches deleted on the remote. Without --prune, deleted remote branches linger in git branch -a output indefinitely.

git fetch --prune
git fetch -p

Make pruning automatic for all fetches (one-time setup sets this):

git config --global fetch.prune true

Pull (fetch plus merge or rebase depending on config).

git pull

Push the current branch, setting upstream the first time. With push.autoSetupRemote from one-time setup, a plain git push does this automatically; -u is only needed where that setting is absent.

git push -u origin main
git push              # after upstream is set

Push a new branch.

git push -u origin <branch-name>

Delete a remote branch.

git push origin --delete <branch-name>

Force-push. Overwrites remote history. Use --force-with-lease --force-if-includes instead of plain --force: the lease refuses the push if the remote changed unexpectedly, and --force-if-includes (Git 2.30+) additionally verifies your local history contains everything on the remote ref. Aliased as git pf in one-time setup. The lease alone isn’t enough right after a fetch: it compares the remote with what you last fetched, so after a pull that stopped on a conflict, --force-with-lease deletes the commit you just fetched. --force-if-includes refuses that push, because your branch doesn’t contain the commit.

git push --force-with-lease --force-if-includes

Keep a linear history on pull instead of generating merge commits. Rebase your local commits on top of what you fetched, ad hoc or as the default.

git pull --rebase
git config --global pull.rebase true        # make rebase the default for every pull
git config --global pull.ff only            # otherwise, refuse pulls that aren't fast-forward

Setting pull.rebase true is the durable choice for solo work: when local and remote have both moved, a plain git pull rebases instead of stopping with an error (Git 2.33.1 and later) or making a merge bubble (older Git).

Set or retarget the upstream-tracking branch without pushing. Use when a branch was created or pushed without -u, or to point it at a different remote branch.

git branch -u origin/<branch>
git branch --set-upstream-to=origin/<branch>     # long form

Removing and renaming tracked files

Both operations stage their change immediately, so the next commit records them.

Remove a tracked file from the repo and the working tree.

git rm <file>
git rm -r <dir>                      # recursively, for a directory

Rename or move a tracked file. Git records a rename as a delete plus an add and re-infers it later by content similarity, so do the rename in its own commit and edit the file’s contents in a separate commit. A combined rename-and-edit commit can drop the similarity below Git’s detection threshold and break git log --follow, git blame across the rename, and GitLens history.

git mv <old> <new>

To stop tracking a file while keeping it on disk (the inverse of git add), see git rm --cached under Ignoring files.

Ignoring files

Create a .gitignore at the repo root. Each line is a pattern.

# comments start with hash, at the start of a line only
*.log
node_modules/
.env
.DS_Store
build/
# leading slash: only at repo root
/dist
# negation: don't ignore this one
!important.log

Start from a language-specific template at github.com/github/gitignore.

Global ignore for OS and editor clutter that should never be tracked.

git config --global core.excludesfile ~/.gitignore_global

Stop tracking a file that’s already tracked, without deleting it locally. Needed because .gitignore only affects untracked files.

git rm --cached <file>

Ignore a file in this repo only, without committing a change to the shared .gitignore. Patterns go in .git/info/exclude, which lives in the repo’s .git directory and is never committed. Use it for personal editor or scratch files that shouldn’t be imposed on collaborators.

# .git/info/exclude  --  same pattern syntax as .gitignore
.scratch/
notes-to-self.md

.gitattributes template

A .gitattributes file at the repo root tells Git how to handle specific file types: line endings, binary detection, diff strategy. Committed to the repo, so every clone gets the same rules. Overrides per-machine core.* settings, which is why it’s preferred for anything that should apply consistently across collaborators.

Combined template covering line endings and binary markers:

# Line endings: normalize to LF in repo, convert on checkout as needed
* text=auto eol=lf

# Files that must keep CRLF (Windows-specific scripts)
*.bat   text eol=crlf
*.cmd   text eol=crlf

# Binary files: don't normalize, don't try to diff
*.png   binary
*.jpg   binary
*.gif   binary
*.pdf   binary
*.zip   binary
*.mp4   binary
*.mov   binary
*.mp3   binary

Word-level diffs inline (Git has no setting that makes them the default; git dw from one-time setup is the short form):

git diff --word-diff
git diff --word-diff=color

If a repo already has mixed line endings, renormalise once after adding the template:

git add --renormalize .
git commit -m "Normalize line endings"

Per-machine line-ending fallback. Use only when .gitattributes isn’t an option (e.g., contributing to a repo whose maintainers haven’t adopted the file). Travels with the machine, not the repo, so it’s the inferior choice when you control the repo:

git config --global core.autocrlf input     # Linux/macOS
git config --global core.autocrlf true      # Windows

Inspecting state and history

Show the full log.

git log

Show a compact one-line-per-commit log.

git log --oneline

Visualise all branches as a graph.

git log --oneline --graph --all --decorate

Filter log by author.

git log --author="name"

Filter log by date range.

git log --since="2 weeks ago"
git log --since="2026-01-01" --until="2026-03-01"

Search commit history for a string that was added or removed (pickaxe).

git log -S "search string"
git log -G "regex pattern"   # regex variant

Show what changed in a specific commit.

git show <sha>

Show a file as it existed at a specific commit.

git show <sha>:path/to/file
git show HEAD~3:path/to/file

Diff: unstaged changes against the staging area.

git diff

Diff: what’s staged for the next commit.

git diff --staged

Diff between two commits.

git diff <sha1> <sha2>
git diff HEAD~3 HEAD

Diff between two branches (any branch name works as a ref).

git diff main..feature-branch
git diff main feature-branch        # same thing, two-dot form omitted

Diff against the upstream-tracking branch. Useful for “what am I about to push” before git push.

git diff @{u}..                     # current branch vs its upstream
git log @{u}..                      # local commits not yet on upstream
git log ..@{u}                      # upstream commits not yet local

Diff against an earlier state of HEAD (from the reflog). Useful for “what changed in my last reset/rebase/merge.”

git diff HEAD@{1}                   # current state vs HEAD one move ago
git diff HEAD@{1}..HEAD             # commits only, leaving out uncommitted work
git diff HEAD@{yesterday}           # time-based reflog reference

Diff at word level (better for prose than line level).

git diff --word-diff

Who last modified each line of a file, and in which commit.

git blame <file>
git blame -L 20,40 <file>   # limit to lines 20-40

Find which commit introduced a bug by binary search.

git bisect start
git bisect bad                    # current commit is broken
git bisect good <sha>             # known good commit
# git checks out a midpoint; you test it and run:
git bisect good   # or: git bisect bad
# repeat until git names the culprit, then:
git bisect reset

Automate the search by handing bisect a test command instead of judging each step by hand. Git runs the command at every midpoint and reads its exit code: 0 is good, 1 to 127 except 125 is bad, 125 means skip this commit as untestable. The command can be a test script, a one-liner, or a compiler invocation, and bisect names the first bad commit with no further interaction.

git bisect start HEAD <known-good-sha>
git bisect run ./test.sh
git bisect reset

Show every commit that touched a file, following it across renames. Without --follow the history stops at the rename; keeping renames content-pure is what makes --follow reliable (see the concepts doc). Aliased as git lf.

git log --follow -- <file>
git log --oneline --follow -- <file>

Show a file’s history with the actual diffs, or with per-commit change stats.

git log -p -- <file>        # full patch at each commit touching the file
git log --stat              # files changed and line counts per commit

Show the history of specific lines or a single function, with the diff at each step. The line-range form takes start and end; the function form names the symbol and lets Git find its bounds. This is the time axis to blame -L’s snapshot: blame says who last touched each line, -L says how the region got there.

git log -L 40,60:path/to/file        # history of lines 40-60
git log -L :funcName:path/to/file    # history of one function, bounds auto-detected

Search tracked content. Faster than a plain recursive grep, respects .gitignore, and can search any revision rather than just the working tree.

git grep "pattern"
git grep "pattern" <sha>            # search the tree at a past commit
git grep -n "pattern"               # show line numbers

Find which branches or tags contain a given commit. Answers “which release shipped this fix.”

git branch --contains <sha>
git tag --contains <sha>

Diff a branch against the point where it diverged, not against the other branch’s current tip. The three-dot form diffs from the merge-base, so it shows only what your branch added; the two-dot form above mixes in the other branch’s later commits.

git diff main...feature             # changes on feature since it forked from main

Compare two versions of a commit series, such as a branch before and after a rebase, or v1 against v2 of the same work. It pairs commits up by content and shows a diff of the diffs, so you can confirm a rewrite changed only what you meant it to. The reflog form feature@{1} is the quickest before-and-after right after a rebase.

git range-diff main..feature@{1} main..feature   # this branch, before vs after the last rebase
git range-diff <base> <v1-tip> <v2-tip>          # explicit three-arg form

Keep git blame readable across bulk-reformat commits. List the reformat commits’ shas in a .git-blame-ignore-revs file (one per line) and blame skips them, attributing each line to its last meaningful change instead.

git blame --ignore-rev <sha> <file>
git config blame.ignoreRevsFile .git-blame-ignore-revs    # apply the file by default

Undoing

Discard unstaged changes to a file. Destroys your edits.

git restore <file>
git restore .   # all files in current directory

Unstage a file (moves it from staged back to modified, does not change its contents).

git restore --staged <file>

Fix the most recent commit’s message. Local only.

git commit --amend -m "new message"

Add forgotten changes to the most recent commit. Local only.

git add <forgotten-file>
git commit --amend --no-edit

Undo the last amend, restoring the pre-amend commit. The amend replaced HEAD with a new commit and left the original in the reflog; this points HEAD back at it. The target is HEAD@{1} (the reflog entry of the original commit), not HEAD~1 (its parent): reset --soft HEAD~1 would undo the original commit along with the amend. --soft keeps everything the amend touched staged, so nothing is lost.

git reset --soft HEAD@{1}
git reset --hard HEAD@{1}    # discard the amend's changes too; destructive

HEAD@{1} is correct only if the amend was the most recent action that moved HEAD. If you’ve committed, checked out, or reset since, read git reflog and reset to the original commit’s explicit sha instead.

Re-stamp the author of the last commit. Use after a commit lands under the wrong identity (the bug the Identity setup section warns about). --reset-author re-reads the current user.name and user.email; the explicit form sets a specific author.

git commit --amend --reset-author --no-edit
git commit --amend --author="Name <email>" --no-edit

Rewrites the commit (new hash), so it’s local-only or force-push with git pf after.

Undo the last commit but keep the changes staged.

git reset --soft HEAD~1

Undo the last commit and unstage the changes (keep them in working directory).

git reset HEAD~1

Undo the last commit and discard the changes entirely. Destructive.

git reset --hard HEAD~1

Create a new commit that reverses a previous one. Safe on pushed history.

git revert <sha>

Revert a merge commit. Plain git revert fails on a merge because it can’t tell which parent to treat as mainline; -m 1 selects the first parent (the branch you merged into).

git revert -m 1 <merge-sha>

Reset vs revert: reset rewrites history and is local-only safe; revert adds a new commit that undoes the target and is safe to push.

Preview untracked files Git would delete (always run this before git clean -fd). The -n flag is dry-run; nothing is deleted.

git clean -nd
git clean -ndx     # also include ignored files

Actually delete untracked files. Destructive: files not tracked by Git are gone, including any new work you forgot to add. Run -nd first.

git clean -fd      # untracked files and directories
git clean -fdx     # also ignored files (.env, build artifacts)

Restore one file to its state at another commit or branch, leaving everything else untouched. Unlike git restore <file> (which discards to the staged version), --source pulls the file’s content from any ref.

git restore --source=<sha> <file>
git restore --source=main <file>     # the version on another branch

Then add and commit it as normal. It’s non-destructive: all prior commits survive and the old state appears as a new tip. Interactive rebase is the alternative but rewrites history, so it’s only right when you actually want the intervening commits gone, not just the file rolled back. Pasting the old content in by hand gives an identical commit; the command is faster, skips clipboard and editor surface area that can corrupt whitespace or encoding, and works on binary files where paste isn’t an option.

Undo a just-completed merge, rebase, or pull. Each of these sets ORIG_HEAD to the tip before the operation, so a hard reset to it backs the whole operation out.

git reset --hard ORIG_HEAD

For an operation still in progress (conflicts unresolved), use --abort instead; ORIG_HEAD is for one that already completed.

Quick recipes for common messes

Committed to the wrong branch.

git reset --soft HEAD~1         # undo the commit, keep the changes staged
git stash                        # save them
git switch <correct-branch>
git stash pop                    # changes come back unstaged
git add -A
git commit -m "message"

Accidentally committed a huge file.

git reset HEAD~1                # undo the commit
# remove or gitignore the huge file, then recommit

Works only while the commit is unpushed; once pushed, the blob is in history and needs the Purging section.

Accidentally committed a secret. Rotate the secret first (assume it’s compromised). Then purge from history with the gitdel script from Git history delete or git filter-repo. Force-push.

Rolled back too far with git reset --hard.

git reflog                       # find the sha from before the reset
git reset --hard <sha>

Want to throw away all local changes and match the remote exactly.

git fetch origin
git reset --hard origin/main

Want to see who changed a line and why, three years later.

git blame <file>                 # find the sha
git show <sha>                   # read the commit message

Branches

List local branches. Current branch is marked with *.

git branch

List all branches including remotes.

git branch -a

Create a branch (stays on current branch).

git branch <name>

Switch to an existing branch.

git switch <name>

Create and switch in one step.

git switch -c <name>

Look at an old commit without affecting any branch (detached HEAD). Useful for inspecting a historical state. Switch back to a real branch when done; commits made in detached HEAD without a branch become unreferenced.

git switch --detach <commit-sha>
git switch -                 # back to previous branch

Older equivalent still widely used.

git checkout <name>
git checkout -b <name>

Rename the current branch.

git branch -m <new-name>

Delete a branch that has been merged.

git branch -d <name>

Force-delete a branch that hasn’t been merged. Destroys unmerged work on that branch.

git branch -D <name>

Merge another branch into the current one.

git merge <other-branch>

Abort a merge in progress (after conflicts).

git merge --abort

Rebase current branch onto another. Local only if the current branch has been pushed.

git rebase <base-branch>
git rebase --abort        # back out mid-rebase
git rebase --continue     # after resolving conflicts

List branches by merge status, to see what’s safe to delete and what still has unmerged work.

git branch --merged main            # already merged into main; safe to delete
git branch --no-merged main         # still hold unmerged commits

Control how a merge is recorded. --no-ff forces a merge commit even when a fast-forward is possible, preserving the branch as a visible unit; --ff-only refuses to merge unless it can fast-forward, rejecting anything that would create a merge commit.

git merge --no-ff <branch>
git merge --ff-only <branch>

Drop the current commit during a rebase. Use only when the rebase stops on a commit whose changes are already present upstream and applying it is redundant. Anywhere else, --skip throws that commit’s changes out of the result; in a fixup/autosquash rebase that means the fix you queued silently vanishes. When in doubt, resolve and continue, or git rebase --abort and rethink.

git rebase --skip

Transplant a range of commits onto a new base, or drop a range. --onto takes the new base, the old base (an exclusive lower bound), and the branch to move.

git rebase --onto <newbase> <upstream> <branch>
git rebase --onto HEAD~3 HEAD~1     # replay HEAD onto HEAD~3, dropping HEAD~1 and HEAD~2

Read it as: replay the commits in <upstream>..<branch> on top of <newbase>.

Merge conflicts

When git merge, git pull, or git rebase stops with conflicts, git status lists the conflicted files. Each conflicted file contains markers:

<<<<<<< HEAD
your version
=======
their version
>>>>>>> other-branch

Edit the file to the final desired content, remove all three markers, then:

git add <file>
git commit              # if merging
git rebase --continue   # if rebasing

To abandon the attempt entirely:

git merge --abort
git rebase --abort

Take one whole side of a conflicted file instead of editing markers by hand, then stage it.

git checkout --ours <file>          # keep your side
git checkout --theirs <file>        # take the incoming side
git add <file>

The meaning inverts between merge and rebase. In a merge, --ours is your current branch and --theirs is the branch being merged in. In a rebase the sides are swapped: --ours is the branch you’re replaying onto and --theirs is your own commit being replayed. Check which operation you’re in before picking a side.

If a pull stops on a conflict

A pull that stops on a conflict is half done. Git fetched the other side’s commit and started combining it with yours, then stopped, so until you finish or abort, your branch still doesn’t contain that commit. Every push fails (after a merge with the same advice to pull, after a rebase with fatal: You are not currently on a branch.), and running git pull again doesn’t help. git status shows the state (You have unmerged paths. after a merge, interactive rebase in progress after a rebase), and so does the prompt from one-time setup (MERGING or REBASE).

See which commit came in, and who made it:

git log --format='%h  %an  (committed by %cn)  %cr  %s' main..origin/main

Expected: one line per incoming commit. committed by GitHub means it was made in GitHub’s web editor; your own name means it came from another clone or machine. Pulling at the start of each session, before you edit, avoids most of these: a pull with nothing local to combine just fast-forwards.

Finish in the mode the pull used. After a merge (pull.rebase false):

git checkout --ours <file>      # keep your version
git checkout --theirs <file>    # or keep the incoming one
git add <file>                  # marks the conflict resolved
git commit --no-edit            # concludes the merge

After a rebase (pull.rebase true, this setup’s default), the sides swap:

git checkout --theirs <file>    # keep your version
git checkout --ours <file>      # or keep the incoming one
git add <file>
git rebase --continue           # git rc

Expected: git checkout prints Updated 1 path from the index, and git add prints nothing. git commit --no-edit prints one [main <hash>] Merge branch 'main' of ... line. git rebase --continue opens your editor on the replayed commit’s message; save and close it, and Git prints Successfully rebased and updated refs/heads/main. Lines starting Recorded preimage or Recorded resolution are rerere saving your resolution, not errors. Then push as usual.

To back out instead, git merge --abort or git rebase --abort (git ra) returns you to just before the pull, with your commits intact.

Three traps:

  1. Staging everything. Other uncommitted edits in the tree would go into the merge commit, and git commit -a commits the conflict markers along with them. Stage only the conflicted file and commit the other edits separately afterwards. ca, caa, caam and rv refuse in this state (note 9 under one-time setup), and the commit hook refuses staged markers.
  2. Picking a side after git add. Once the file is staged, git checkout --ours and --theirs quietly do nothing (Updated 0 paths from the index). Bring the conflict back first with git checkout -m <file>, which prints Recreated 1 merge conflict, then pick.
  3. Force-pushing past the rejection. Right after a fetch, git push --force-with-lease passes its check and deletes the incoming commit from the remote. git pf adds --force-if-includes, which refuses because your branch doesn’t contain that commit.

Stashing

Save uncommitted changes and revert the working directory to the last commit.

git stash push -m "what I was working on"

List stashes.

git stash list

Reapply the most recent stash and remove it from the stash list.

git stash pop

Reapply a stash but keep it in the list.

git stash apply stash@{0}

Drop a stash without applying.

git stash drop stash@{0}

Clear all stashes.

git stash clear

Promote a stash to a branch. Creates a new branch from the commit the stash was made on, then applies the stash on top. Useful when a stash turns out to be larger than expected or conflicts with the current branch.

git stash branch <new-branch-name>
git stash branch <new-branch-name> stash@{1}   # specific stash

Include untracked files in the stash. Plain git stash leaves new (untracked) files in the working directory, a common cause of “my stash didn’t save everything.”

git stash push -u -m "what I was working on"
git stash -u                         # short form, no message

Inspect a stash’s contents before applying it.

git stash show -p stash@{0}          # full diff of the stash
git stash show stash@{0}             # summary only

Rewriting history (local only)

Interactive rebase opens an editor with the last N commits listed, each prefixed with pick. Change the prefix to alter what happens to that commit:

  1. reword: keep the commit, change the message.
  2. squash (or s): fold this commit into the one above, combining messages.
  3. fixup (or f): fold into the one above, discarding this commit’s own message.
  4. edit: stop at this commit so you can amend it, then git rebase --continue.
  5. drop (or d): remove the commit entirely. Same effect as deleting its line.

Reorder commits by reordering lines in the editor.

git rebase -i HEAD~5

Skip the todo-list editor for a straight squash-all-into-first via environment variable; Git still opens the editor once, for the combined message:

GIT_SEQUENCE_EDITOR="sed -i 's/^pick/squash/; 1s/squash/pick/'" git rebase -i HEAD~5

Non-interactive shortcut for the collapse-all-into-one case: reset --soft plus a fresh commit. Cleaner than interactive rebase when all you want is to combine the last N commits into one new commit with a new message.

git reset --soft HEAD~4
git commit -m "your message"

reset --soft moves the branch pointer back N commits but keeps all the changes staged. The single new commit captures everything. No editor. Limited to the straight-collapse case; use rebase -i for reorder, edit, drop, or selective squash. The aliased forms uncommit (for HEAD~1) and squash (general) are defined in the one-time setup.

Rebase preserving merge commits.

git rebase -i --rebase-merges HEAD~5

Apply a single commit from elsewhere onto the current branch.

git cherry-pick <sha>
git cherry-pick <sha1> <sha2> <sha3>

When a cherry-pick hits a conflict, resolve and continue, drop the conflicting commit, or back out entirely.

git cherry-pick --continue           # after staging the resolution
git cherry-pick --skip               # drop this commit, keep going
git cherry-pick --abort              # undo the whole cherry-pick

Recovery

The reflog is your safety net. It records every move of HEAD for ninety days by default, including operations that “lost” commits, though an entry for a commit the current tip can no longer reach expires after thirty. Almost nothing is truly gone.

Show the reflog.

git reflog

Recover a commit by its sha from the reflog.

git checkout <sha>                               # look at it
git branch recovery <sha>                        # save it as a branch
git update-ref refs/heads/<branch> <sha>         # force a branch to point at it

Recover after a bad reset. Find the sha of the commit you were on before the reset in the reflog, then:

git reset --hard <sha>

Find dangling commits that the reflog doesn’t surface.

git fsck --lost-found

Tags

List tags.

git tag

Create a lightweight tag (just a pointer).

git tag v1.0.0

Create an annotated tag (recommended for releases; carries a message and metadata).

git tag -a v1.0.0 -m "First stable release"

Push a specific tag.

git push origin v1.0.0

Push commits plus any annotated tags reachable from them in a single operation. Useful when releases are tagged on commits you’re about to push; avoids the two-step push + push –tags.

git push --follow-tags

Push all tags.

git push --tags

Delete a local tag.

git tag -d v1.0.0

Delete a remote tag.

git push origin --delete v1.0.0

Get a human-readable name for the current commit relative to the most recent tag. Output like v1.2.0-5-gabc123 means five commits after v1.2.0, at commit abc123.

git describe --tags
git describe --tags --dirty          # append -dirty if the working tree has changes

SSH setup for GitHub

Generate a key, and give it a passphrase when asked. The key authenticates your pushes and, under Signing, signs your tags, so a copy of the file is useless to anyone without the passphrase.

ssh-keygen -t ed25519 -C "you@example.com"

Expected: it asks where to save (press Enter for ~/.ssh/id_ed25519), then Enter passphrase (empty for no passphrase): and Enter same passphrase again:, and prints the key’s fingerprint and a randomart picture.

Check whether an existing key has a passphrase, and add one if not:

ssh-keygen -y -P "" -f ~/.ssh/id_ed25519 >/dev/null 2>&1 && echo "no passphrase" || echo "has a passphrase"
ssh-keygen -p -f ~/.ssh/id_ed25519

Expected from ssh-keygen -p: Key has comment '<comment>', then Enter new passphrase (empty for no passphrase): and Enter same passphrase again:, then Your identification has been saved with the new passphrase. On a key that already has one, it asks Enter old passphrase: first.

Load the key into ssh-agent once per login, so pushes and signed tags don’t ask for the passphrase every time:

ssh-add -l                     # is an agent running?
eval "$(ssh-agent -s)"         # only if the line above couldn't connect
ssh-add ~/.ssh/id_ed25519

Expected: ssh-add -l prints The agent has no identities. when an agent is running but empty, or Could not open a connection to your authentication agent. when none is; then eval prints Agent pid <number>. ssh-add asks Enter passphrase for /home/<you>/.ssh/id_ed25519: and prints Identity added: /home/<you>/.ssh/id_ed25519 (you@example.com). Without the agent, every push and every signed tag asks for the passphrase.

Copy the public key and add it to GitHub at Settings > SSH and GPG keys > New SSH key, with the key type Authentication Key. Signing adds it a second time, as a Signing Key.

cat ~/.ssh/id_ed25519.pub

Verify the connection.

ssh -T git@github.com

Expected: Hi <username>! You've successfully authenticated, but GitHub does not provide shell access., with exit status 1, which is normal here. The first time, ssh asks whether to trust github.com: answer yes only if the ED25519 fingerprint it shows is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU, the one GitHub publishes.

Use SSH URLs for remotes (git@github.com:user/repo.git) rather than HTTPS URLs once SSH is set up.

Identity setup

Single-identity setup with directory binding and fail-loud safety. The global config carries no identity; the identity is bound to a directory via includeIf gitdir:. A repo created outside that directory errors instead of committing under whatever happened to be in global config.

For pseudonymous work see Pseudonymous work under One-time setup, above; this section is the mechanics for one identity on this OS account.

Fail-loud global

The global config carries no usable identity. user.useConfigOnly (Git 2.8+) then makes a repo outside the configured identity directory refuse to commit (“Author identity unknown”) instead of silently stamping whichever identity happened to be global.

git config --global user.useConfigOnly true
git config --global --unset user.name     # remove any existing global identity
git config --global --unset user.email

useConfigOnly only stops Git guessing or falling back to a default; on its own it does nothing if a global user.name/user.email is still set. The unset lines are what close the fallback.

Identity include file

The identity lives in its own include file that sets name, email, signing config, and the SSH key to use. The core.sshCommand line pins the key by directory, so the right key is used even when a remote URL was never rewritten to a Host alias. Signing here covers tags only; Signing explains why, and what the allowedSignersFile line is for.

~/.gitconfig-me:

[user]
    name = Your Name
    email = you@example.com
    signingkey = ~/.ssh/id_ed25519.pub
[gpg]
    format = ssh
[gpg "ssh"]
    allowedSignersFile = ~/.ssh/allowed_signers
[tag]
    gpgsign = true
[core]
    sshCommand = ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes

Set the file to chmod 600 so other local users on the same machine can’t read it.

Bind the identity to a directory

In ~/.gitconfig:

[includeIf "gitdir:~/code/me/"]
    path = ~/.gitconfig-me

A repo under ~/code/me/ resolves the identity; anything elsewhere has no identity and fails loud. Three caveats on the match:

  1. The trailing slash matters. Git turns ~/code/me/ into ~/code/me/**, which matches everything inside; without the slash the pattern has to match a repo’s .git path exactly, so ~/code/me matches no repo under it at all.
  2. gitdir: matches the canonicalised path of the repo’s .git. A repo reached through a symlinked parent can miss the match; point the condition at real paths.
  3. Use gitdir/i: instead of gitdir: for a case-insensitive match on a case-insensitive filesystem.

Alternative: bind by remote URL

If your repos won’t all live under one identity directory, key the include off the remote URL instead (Git 2.36+, April 2022):

[includeIf "hasconfig:remote.*.url:git@github.com:youruser/**"]
    path = ~/.gitconfig-me

One trap: you cannot define a remote inside a file included this way. The condition needs the remote to exist before the file that would define it is read, so a [remote] block there is a circular dependency Git rejects; keep remotes in the repo’s own config. Directory binding sets identity at git init, before any remote exists; remote binding activates only once a remote is set, so a fresh repo has no identity until then. Prefer directory binding by default.

Check the active identity

Before committing in an unfamiliar repo, confirm who you are. Add an alias once:

git config --global alias.whoami '!git config user.name; git config user.email; git config user.signingkey'

Then, inside any repo:

git var GIT_AUTHOR_IDENT                # the identity a commit here would record
git whoami                              # name, email, signing key from config
git config --show-origin user.email     # and which file set the email

Trust the first line. git var GIT_AUTHOR_IDENT prints Name <email> <timestamp> <zone>, the identity a commit would record, including one set by exported GIT_AUTHOR_* and GIT_COMMITTER_* variables, which git whoami can’t see. With the fail-loud global in place, in a repo outside the identity directory it fails the way a commit does: Git’s Author identity unknown hint, then fatal: no email was given and auto-detection is disabled. That failure is the safety net, not a misconfiguration; ignore the hint’s advice to set a global identity, which would undo it. If git whoami comes back empty but git var prints a name, an exported variable is overriding the setup, and env | grep '^GIT_' shows which.

Signing

This setup signs tags, not commits. A tag names its commit by hash, and every commit names its files and its parent by hash, so one signature on a tag vouches for everything behind it. The Linux kernel works the same way: Linus pulls only from signed tags. Signing every commit adds a per-commit “Verified” badge on GitHub, at the cost of re-signing after every history rewrite, because filter-repo, and so the gitdel script, strips commit signatures. Signing in the concepts doc has the reasoning.

Two methods: SSH (Git 2.34+, GitHub support since 2022) and GPG. SSH reuses the key from SSH setup for GitHub and is simpler to set up; GPG is the long-standing standard. Pick one; don’t combine.

SSH signing

The settings belong in the identity include file from Identity setup, so signing follows the identity directory:

git config --file ~/.gitconfig-me gpg.format ssh
git config --file ~/.gitconfig-me user.signingkey ~/.ssh/id_ed25519.pub
git config --file ~/.gitconfig-me tag.gpgsign true
git config --file ~/.gitconfig-me gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers

Expected: no output. With tag.gpgsign on, every tag is signed, even a plain git tag <name>, which then asks for a message. To sign every commit as well, add git config --file ~/.gitconfig-me commit.gpgsign true.

Verification needs an allowed_signers file, which says whose key may sign as which email. Add your own line once, with your email:

echo "you@example.com namespaces=\"git\" $(cut -d' ' -f1,2 ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers

Expected: no output. The namespaces="git" annotation is what tells ssh-keygen (and Git through it) that this key is trusted for Git signatures specifically, not other SSH-signed contexts.

Sign a tag and check it:

git tag -s v1.0.0 -m "First stable release"
git verify-tag v1.0.0

Expected: git tag -s prints nothing, or asks Enter passphrase: when ssh-agent doesn’t hold the key. git verify-tag prints Good "git" signature for you@example.com with ED25519 key SHA256:…. A line such as allowed_signers:2: invalid key points at a malformed line in that file; it doesn’t stop your own tag verifying.

Upload the same public key to GitHub a second time, at Settings > SSH and GPG keys > New SSH key, with the key type set to Signing Key: a separate entry from the authentication key, even though the key material is identical. Without it GitHub shows your tags as unverified.

After a history rewrite, a tag’s signature no longer verifies: filter-repo either removes it or leaves one that fails with Could not verify signature. Re-sign each tag where it now points, then force-push it:

git tag -s -f v1.0.0 'v1.0.0^{}' -m "First stable release"
git push --force origin v1.0.0

Expected: Updated tag 'v1.0.0' (was <hash>), then the push reports + <old>...<new> v1.0.0 -> v1.0.0 (forced update).

GitHub’s vigilant mode marks every unsigned commit and tag attributed to you as “Unverified”. With tags-only signing, leave it off: GitHub says to turn it on only if you sign all your commits and tags.

The gpg.format and tag.gpgsign names are misleading: they govern SSH signing too, and the keys kept gpg in their names from before SSH signing existed.

GPG signing

git config --file ~/.gitconfig-me user.signingkey <key-id>
git config --file ~/.gitconfig-me tag.gpgsign true
git tag -s v1.0.0 -m "First stable release"

Leave gpg.format unset for GPG; it’s the default.

List your GPG keys.

gpg --list-secret-keys --keyid-format=long

Verify signatures rather than create them. The first shows signature status inline in the log; the others check a specific commit or tag and exit nonzero on failure.

git log --show-signature
git verify-commit <sha>
git verify-tag <tag>

SSH-signature verification needs the allowed_signers file from the SSH signing setup above; without it these report an error instead of a verdict.

Worktrees

Check out a second branch in a parallel working directory without cloning the repo again. Useful for quick context switches.

git worktree add ../other-branch-folder <branch>
git worktree list
git worktree remove ../other-branch-folder

Submodules and LFS

Both are advanced features that usually cause more friction than they solve for personal projects. See Large binaries are permanent and the submodule item in What Git silently discards, both in the concepts doc, for why. Minimal commands when you do need them.

Add a submodule (pin another repo at a specific commit inside this repo).

git submodule add <url> <path>
git submodule update --init --recursive

Clone a repo that has submodules (without this, the submodule directories will be empty).

git clone --recurse-submodules <url>

LFS install and track. Requires git-lfs installed (sudo apt install git-lfs).

git lfs install                        # one-time per user
git lfs track "*.psd"                  # tell LFS to handle this pattern
git add .gitattributes                 # the track command writes here

Files matching tracked patterns are stored in LFS instead of the regular git object store.

Purging files from history

Removing a file in a commit does not remove it from history; earlier commits still contain it. To actually purge a file from every commit in every branch, history has to be rewritten.

The tool depends on what’s being scrubbed:

  1. Specific deleted files, with rename-chain awareness: the gitdel script from Git history delete, this project’s wrapper around git filter-repo.
  2. Files matching a glob, or files over a size threshold: BFG Repo-Cleaner.
  3. Secrets or other content inside files to be kept: BFG --replace-text or git filter-repo --replace-text.
  4. Surgical edits to the last N commits (unpushed or coordinated): git rebase -i. See the Rewriting history (local only) section above for commands.
  5. Never: git filter-branch. Not recommended by the git project itself.

After any rewrite: force-push, then assume platform-side caches (forks, PR pages, mirror clones, search indices) still hold the old content. Rewrite is the start of a leak response, not the end.

git filter-repo

The modern tool. The filter-branch man page now points users here. Python-based, supports path filtering, content replacement, ref renaming, and per-blob callbacks. Refuses to operate on non-fresh clones by default; --force bypasses, which is what the gitdel script uses internally because the wrapper performs its own working-tree cleanliness checks. The typical trigger on a follow-up invocation is a prior rewrite in the same repo whose .git/filter-repo/ directory has since been removed (the gitdel script and the crash fix below both remove it): the first run took out origin, so the check fails with “expected one remote, origin” even though the working tree is otherwise clean. While that directory is in place, follow-up runs skip the check.

Install: sudo apt install git-filter-repo.

Purge a single file from all history.

git filter-repo --invert-paths --path secret.env

Purge multiple paths listed in a file, one per line.

git filter-repo --invert-paths --paths-from-file paths-to-purge.txt

Rename a path across all of history, so the file reads under the new name from the commit that first added it and no rename event appears. The argument is matched against the full repo-relative path from the root, at path-component boundaries rather than as a raw string prefix. A path matches only when it equals OLD exactly or continues with a / after it, so old/path:new/path rewrites old/path and everything under old/path/ but leaves old/path2 and old/path-archive untouched even though they share the leading text. It rewrites every commit from the add point forward, with new hashes, broken signatures, and a force-push, so the post-rewrite hygiene below applies.

git filter-repo --path-rename old/path:new/path

Operational points about this flag. A trailing slash makes the argument a directory rename bounded to that directory’s contents: old-dir/:new-dir/ moves everything under old-dir/ and does not match a file literally named old-dir. The slashes must agree on both sides; filter-repo errors on old/:new or old:new/, accepting only both-slash, neither-slash, or an empty side (old-dir/: lifts the directory’s contents to the repo root). Without slashes the argument names a single path component, file or directory: old.md:new.md renames the file old.md, or if old.md is a directory it renames the directory and everything under it, but never a sibling like old.md.bak whose name merely starts with the same text. A colon makes filter-repo error, it does not silently mis-split: the argument is split on every colon and must yield exactly two fields, so any path containing a colon aborts with “expects one colon”; rename such paths with --paths-from-file using an ==> line, which is split on ==> and tolerates colons in the path. The rename never selects paths: a bare --path-rename keeps every other path and only relabels matches, so files vanish only when a --path filter is also present, which is a whitelist that drops everything unmatched, or when a rename lands on a path that already exists in the tree, which is silently overwritten. A rename collision inside a single commit errors loudly with “File renaming caused colliding pathnames!” unless one side is a deletion or the two blobs are identical, so two distinct files folded onto one name fail fast rather than losing data quietly. filter-repo rewrites every ref by default (heads, tags, stashes), so a single --path-rename invocation propagates the rename across every branch and tag in the repo; this is what you usually want for a file-shape change but is the same blast-radius point that the gitdel script’s TRACKED_SET block already documents.

This rewrites history, removes the origin remote and its remote-tracking refs by design, and breaks other clones of the repo. After running, re-add origin and force-push, and tell any collaborators to re-clone from scratch.

If filter-repo refuses with “not a fresh clone”

Aborting: Refusing to destructively overwrite repo history since this does not look like a fresh clone. (expected freshly packed repo) ... Please operate on a fresh clone instead. If you want to proceed anyway, use --force.

This is filter-repo’s pre-rewrite safety check, refusing to start because the repo doesn’t look like a fresh clone; the reason in brackets names the test that failed, here the pack state. The check exists because the standard recovery from a bad rewrite is to discard the clone and re-clone, and that recovery only works if a forge copy holds the pre-rewrite state and you have no local-only commits the forge doesn’t.

Two paths.

Add --force to the same command and re-run when the repo mirrors to a forge you can re-clone from, has no local-only commits, and you accept that the throw-the-clone-away recovery posture is now provided by the forge mirror, not by the check. This is what the gitdel script does internally for exactly this reason.

git filter-repo --path-rename old/path:new/path --force

Re-clone fresh into a sibling directory, from the forge, or from your working copy when it holds commits the forge doesn’t; run the rewrite there, git push --mirror to every mirror, and discard the original working copy once the rewrite checks out. This is the path the check is enforcing. Choose it when the repo is local-only, when you have any local commits the forge doesn’t (verify with git log @{u}..HEAD per branch), or when you’ve forgotten whether you’ve pushed.

git clone --mirror <forge-url> /tmp/repo.git     # or the path to your working copy
cd /tmp/repo.git
git filter-repo --path-rename old/path:new/path
git push --mirror <forge-url>

The --mirror clone preserves every ref, so the rewrite covers every branch and tag the forge holds, not just the current branch.

If filter-repo crashes after writing new history

New history written in N seconds; now repacking/cleaning... followed by a Python traceback such as FileNotFoundError: ...first-changed-commits means the rewrite finished but filter-repo died in its post-rewrite metadata step before refreshing the working tree. Refs already point at the rewritten history; the index and working tree still show the pre-rewrite snapshot, so a casual ls makes it look as if nothing changed.

This happens specifically when filter-repo finds .git/filter-repo/already_ran from an earlier rewrite older than a day, prompts Treat this run as a continuation of filtering in the previous run (Y/N)?, you answer Y, and the prior metadata is incomplete or written by a filter-repo version that didn’t yet emit first-changed-commits. The bare avoidance is to answer N to that prompt for any new rewrite (the docs say a rewrite shouldn’t count as a continuation once the earlier one’s commits have been shared); the bare fix when you have already answered Y and hit the traceback is below.

First verify HEAD has the rewrite before doing anything destructive.

git ls-tree -r HEAD | grep <new-name>

If that prints a line, refs are correct. The fix is to repoint the working tree and finish the cleanup filter-repo aborted.

git reset --hard HEAD
rm -rf .git/filter-repo/
git reflog expire --expire=now --all
git gc --prune=now --aggressive

reset --hard HEAD replaces the stale index and working tree with what HEAD already points to. Removing .git/filter-repo/ clears state from the partial run; left in place and older than a day, it triggers the same continuation prompt on the next filter-repo invocation in this repo, and another Y answer reproduces the same crash. The reflog expire plus aggressive gc is the cleanup filter-repo would have done if it had reached the end; run it only after ls-tree HEAD verification, since it drops the unreachable-objects grace window that the Recovery section below relies on for restoring a botched rewrite.

If ls-tree HEAD does not show the new path, the rewrite did not match. --path-rename is literal and root-anchored, so a bare filename matches only a file at the repo root. Clean up and re-run with the full repo-relative path on both sides.

rm -rf .git/filter-repo/
git filter-repo --path-rename docs/old.md:docs/new.md --force

--force is needed on the re-run because the repo is no longer a fresh clone after the prior rewrite, but the prior rewrite was a no-op (it matched no paths), so the force-bypass is safe here.

The gitdel script pre-cleans .git/filter-repo/ before invoking filter-repo for exactly this reason; the crash mode above is only reachable when filter-repo is invoked directly without the wrapper.

gitdel

For a safer, validated purge that auto-detects deleted files, handles renames, protects files still live on any branch, and prints the tip of every branch and tag before it rewrites, use the gitdel script on Git history delete.

bash gitdel.sh <repo-path> [file-list.txt] [--dry-run] [--save-list <file>] [--yes]

Always run with --dry-run first.

bash gitdel.sh ~/projects/myrepo --dry-run
bash gitdel.sh ~/projects/myrepo --save-list ~/purge_log.txt

Without a file list, it scans git history for every deleted file and offers to purge them. With a file list, it purges those paths and every other name each one had, after validation; the purge list it shows before you confirm includes them all.

BFG Repo-Cleaner

JVM tool, multi-threaded, can be significantly faster than filter-repo on large repos, especially for size-based stripping. Two distinctive features:

  1. --strip-blobs-bigger-than 100M: remove every blob over a size threshold without enumerating files.
  2. --replace-text rules.txt: rewrite blob contents to redact secrets in-place. The file stays in history; the secret does not.

Weaknesses: no rename awareness, HEAD-only working-tree protection by default, no concept of “files that were deleted but might be on another branch.” Best for size-driven or pattern-driven scrubs, not exact-set surgery.

git filter-branch

Not recommended. The git man page actively recommends against it: orders of magnitude slower than filter-repo, with subtle semantic bugs in path filtering. Mentioned only because old Stack Overflow answers still recommend it. Don’t.

If the only available tool is filter-branch (locked environment, no Python, no JVM), the right move is to install one of the alternatives. The cost of a one-time install is dwarfed by the cost of getting filter-branch wrong on a real repo.

git rebase -i for recent surgical edits

For dropping or amending a few specific recent commits. Works when:

  1. The change is recent (within reach of an interactive rebase).
  2. The commits are unpushed, or the force-push is coordinated with everyone holding the branch.
  3. The scope is a few specific commits, not “every commit that touched file X.”

Beyond that, use filter-repo or BFG. See the Rewriting history (local only) section above for the commands.

Removing specific versions of a file from history

Use case: a tracked file has accumulated history, and you want to erase exactly two (or N) historical versions from the repo while keeping every other version of that file, every other file, and the surrounding commits intact. Common reason: a draft you don’t want preserved, accidentally-committed content that shouldn’t have been there, content you want to archive externally and then scrub from history.

Step 1, find the commits if you don’t already have the SHAs:

git log --all --follow -- path/to/file
git log --all -S 'distinctive substring from the version' -- path/to/file

--follow walks renames. -S (pickaxe) finds commits that added or removed a specific string.

Step 2, extract each version to a location outside the repo before doing anything destructive:

git show <SHA_A>:path/to/file > ~/safekeeping/file_va.txt
git show <SHA_B>:path/to/file > ~/safekeeping/file_vb.txt

git show <sha>:<path> prints the file’s exact contents as of that commit. Verify the extracts are what you wanted before continuing.

Step 3, decide which path to use.

Path A: drop the commits entirely. Use this when the target commits only changed that file, or when you accept losing everything else they did. Cleanest history afterward.

git rebase -i <parent-of-earliest-target>
# in the editor, change `pick` to `drop` on the two target lines

Path B: surgical erase of just the file content at those commits, keeping the commits themselves and any other changes they carried. Use when the target commits touched other files you want to keep.

Find the two blob hashes (the IDs Git uses for the file contents at each commit):

git ls-tree <SHA_A> path/to/file
git ls-tree <SHA_B> path/to/file
# output format: <mode> blob <hash> <path>

Rewrite to blank just those blobs:

git filter-repo --force --blob-callback '
if blob.original_id in (b"<blob_a_hash>", b"<blob_b_hash>"):
    blob.data = b""
'

The two commits remain in history with their other changes intact; the file at those commits is empty. Other versions of the file in other commits survive untouched because they point to different blobs. Anything else with exactly the same content is emptied too, since Git stores identical content as one blob: the same file in later commits that left it unchanged, and any other file holding the same text, including in your current checkout. List what else holds it before you run the callback:

git log --all --oneline --name-status --find-object=<blob_a_hash>

Caveat on Path B: a downstream commit that “added paragraph X” relative to an erased blob will produce an unusual diff afterward, because its parent’s content at that path is now empty. If history readability at those points matters more than commit preservation, prefer Path A.

Post-rewrite for Path A:

git push --force-with-lease
git reflog expire --expire=now --all
git gc --prune=now --aggressive

Path B’s filter-repo run has already expired the reflog and pruned, and it removes origin, so the lease push above fails there; re-add the remote and force-push as in After the rewrite.

If the repo is on a forge (GitHub, Codeberg), forks, PR pages, mirror clones, and search indices may retain the old blobs. If the hidden content is sensitive, assume it’s been seen and rotate whatever the content protected. See Anti-patterns below for the rest of the post-rewrite hygiene.

git replace

The non-rewriting alternative: keep the old commits, graft a replacement in front. Doesn’t propagate to clones unless refs/replace/* is explicitly pushed (rare). Useful for hiding history locally when force-push isn’t available. Not a real secret-scrub mechanism; the original blob still exists in the repo.

Decision tree

Start at the top. Stop at the first match.

  1. Need to remove a specific known set of deleted files, with rename awareness? Use the gitdel script.
  2. Need to strip blobs over a size threshold? Use BFG --strip-blobs-bigger-than.
  3. Need to redact secrets inside files to keep? Use BFG or filter-repo --replace-text.
  4. Need to drop or edit specific recent commits? Use git rebase -i.
  5. Need to remove specific historical versions of a still-tracked file (not the whole file)? Use the Removing specific versions of a file from history recipe above.
  6. Need to remove every file matching a glob, accepting path-literal matching with no rename chasing? Use filter-repo or BFG with the appropriate path filter directly.
  7. Need to hide history locally without force-pushing? Use git replace.

Anti-patterns

  1. Running the rewrite on the canonical working clone. Always use a throwaway. filter-repo’s fresh-clone check exists for a reason.
  2. Force-pushing without coordinating with anyone else holding the branch. Their local history diverges silently; the next merge reintroduces what was scrubbed.
  3. Assuming git gc reclaims space automatically. It doesn’t. Run git reflog expire --expire=now --all && git gc --prune=now --aggressive to actually free disk.
  4. Treating “rewrite succeeded” as “secret is gone.” Platform caches, PR pages, mirror clones, search indices, IDE history, backup systems may all still hold it. Rotate the credential at its source regardless.
  5. Rewriting signed commits without re-signing. Signatures don’t survive a commit-hash change; filter-repo strips them, so downstream verifiers see unsigned commits. With tags-only signing, re-sign the tags as shown under Signing.
  6. Running filter-branch in 2026. Stop.

Recovery

The gitdel script prints the tip of every branch and tag before filter-repo runs. Restore with git update-ref <refname> <sha> if the rewrite goes wrong, as long as the old objects still exist (see below).

For broader coverage across every branch, tag, and the top of the stash stack, capture all refs before the rewrite:

git for-each-ref --format='%(objectname) %(refname)' > /tmp/pre-rewrite-refs.txt

If the rewrite is wrong, restore each ref:

while IFS=' ' read -r sha ref; do
    git update-ref "$ref" "$sha"
done < /tmp/pre-rewrite-refs.txt

This works only while the original objects still exist. As its last step, filter-repo expires the reflog and runs git gc --prune=now, which deletes every object no ref reaches any more, so after a completed filter-repo run, including the gitdel script’s, update-ref fails with “nonexistent object” in that clone. The old commits then survive only on the forge until you force-push, in another clone, or in the tarball below. A rewrite that doesn’t clean up after itself, such as an interactive rebase, leaves them in place until gc prunes them. So: don’t run aggressive gc until the rewrite is verified.

Belt and braces: tarball the entire .git directory before the rewrite. Cheap, and recovers from any failure mode including gc-eaten objects.

After the rewrite

  1. Force-push every remote: git push --force --all && git push --force --tags. filter-repo removes the origin remote by design, so it needs re-adding first; the gitdel script prints those commands.
  2. Rotate the leaked credential at its source. Treat the rewrite as evidence of disclosure, not as a cure.
  3. Notify collaborators. Anyone with a local clone needs to re-clone or carefully re-base their work; existing branches reference commit hashes that no longer exist.
  4. Expect platform caches to lag. GitHub, GitLab, and Codeberg may keep old refs accessible via the API or PR pages for some window. Contact platform support for accelerated removal if the leak is severe.
  5. Verify: clone fresh, then run git log --all --oneline -- <path> for every path you purged (gitdel’s --save-list file lists them all); each prints nothing once the path is gone. For a secret, also search the content, which catches a copy under a name you didn’t purge: git rev-list --all | xargs git grep -n -F '<part of the secret>' prints nothing when it’s gone, and a hit prints <commit>:<path>:<line>:<text>. Search for only part of the secret, so the whole key doesn’t land in your shell history.

Git setup

git-setup.sh sets up Git on a machine in one run, the way the Git reference describes it, so you don’t type the commands one at a time. It writes the global settings, aliases and commit message template from one-time setup; creates an SSH key with a passphrase if you don’t have one; binds your name and email to one folder, so a repo anywhere else refuses to commit rather than guessing who you are; signs your tags and writes the file that git verify-tag checks them against; and installs the commit hook that refuses conflict markers and warns about renames Git would lose track of. You edit a short block at the top of the script, run it, and run it again whenever you change that block: it backs up the files it owns before rewriting them, and leaves the rest of your config alone.

Nothing it does is hidden. Each step is one the reference explains, under SSH setup for GitHub, Identity setup and Signing, and Git concepts covers why the setup works this way. Like the gitdel script on Git history delete, it’s a shortcut over commands you could run yourself, not a different way of using Git.

#!/bin/bash
# Git setup, v22

## ─── Usage ───────────────────────────────────────────────────────────────────
## To set up Git and SSH for a single identity, on a fresh machine or one you
## want to bring into line, edit the CONFIG block below, then run in terminal,
## from any directory:
##   bash git-setup.sh
##
## No arguments. Re-runnable: safe to run multiple times; run it again
## whenever you change the CONFIG block.
## CLONE="on":  also clones the repos listed in CLONE_REPOS onto this machine,
##              or fast-forwards them if they're already there. Off by
##              default; see section 7.
## Privacy:     this script contains your real name and email in plain text.
##              Keep it in a PRIVATE dotfiles repo, not a public one.

## ─── Flow ────────────────────────────────────────────────────────────────────
## Steps are numbered like the sections below. Before step 1, a sanity check
## stops the run if the CONFIG block is unedited, git or ssh-keygen is
## missing, or CLONE isn't "on" or "off", and warns if git-revise is missing.
## 1. Set the global config, with no identity in it (see Scope).
## 2. Keep an existing SSH key, warning if it has no passphrase, or generate
##    one with a passphrase prompt; print the public key.
## 3. Write ~/.gitconfig-me (identity, SSH key, tag signing), bind it to
##    REAL_DIR with includeIf, and write the REAL_EMAIL line in
##    ~/.ssh/allowed_signers.
## 4. Set the aliases, and remove the retired fold aliases.
## 5. Write the commit message template, ~/.gitmessage.
## 6. Write the prepare-commit-msg hook: refuse staged conflict markers, and
##    warn on a rename with substantial content changes.
## 7. If CLONE="on": pin GitHub's host key unless it's already known, then
##    clone each repo in CLONE_REPOS into CLONE_DIR, or fast-forward it if
##    it's already there.
## 8. Prune backups of the managed files that are older than 30 days.
## 9. Print the commands that check the setup.
## 10. Warn about anything that would override the setup: GIT_ variables in
##     this shell or its startup files, other hooks in ~/.git-hooks, and
##     identity, hook or SSH settings in the repos under REAL_DIR.

## ─── Scope ───────────────────────────────────────────────────────────────────
## The script touches only:
##   - global Git config (via `git config --global`, never edits ~/.gitconfig
##     directly; Git owns that file)
##   - ~/.ssh/id_ed25519       (SSH key, generated with a passphrase if missing)
##   - ~/.gitmessage           (commit message template, managed file)
##   - ~/.git-hooks/           (commit hook: conflict markers, rename safety)
##   - ~/.gitconfig-me         (identity, managed file)
##   - ~/.ssh/allowed_signers  (signing key for verify-tag; other lines kept)
##   - ~/.ssh/known_hosts      (github.com host key pinned, only when CLONE="on")
##
## It also reads, but never edits, your shell startup files and the config of
## the repos under REAL_DIR, to warn about overrides (section 10).
##
## Fail-loud identity model: the global config carries NO usable identity and
## sets user.useConfigOnly=true. The identity is bound to a directory via
## includeIf. A repo created outside that directory has no name/email, so Git
## refuses to commit ("Author identity unknown") instead of silently stamping
## whatever was lying around in global config. Put real repos under $REAL_DIR.
## Pre-existing global user.name or user.email is unset on every run (no-op
## on a fresh machine).
##
## Existing files are backed up with a timestamp suffix before being
## rewritten. Backups older than 30 days are pruned at the end of each run.
## Existing SSH keys are never overwritten. A new key is generated with a
## passphrase prompt; an existing key without a passphrase gets a warning
## (add one with ssh-keygen -p; see git_reference § SSH setup for GitHub).
##
## Hook scope: the script sets core.hooksPath = ~/.git-hooks globally, which
## supersedes any per-repo .git/hooks/ directory. A repo that relied on its
## own hooks before this script will silently stop running them; restore by
## setting the repo's own core.hooksPath back to .git/hooks (or unsetting it).
##
## Pseudonymous work: not supported here by design. Config-level identity
## separation on one OS account is structurally weaker than an OS-account or
## VM boundary; a single slip (wrong directory, wrong key, the agent offering
## the wrong key) links the identities, and a host compromise links them at
## once. For a pseudonymous identity, run it from a separate OS user account
## or VM with its own ~/.gitconfig and ~/.ssh, and use this script there too
## if you want the same setup. See git_concepts § Identity layers and
## isolation.

set -euo pipefail
[[ $# -eq 0 ]] || { printf 'Usage: bash git-setup.sh (no arguments; edit the CONFIG block in the script)\n' >&2; exit 2; }

## ─── CONFIG ──────────────────────────────────────────────────────────────────

# Identity. Required. Repos live under REAL_DIR and pick up this identity
# automatically; repos created elsewhere fail loud (see Scope).
REAL_NAME="Your Name"
REAL_EMAIL="you@example.com"
REAL_DIR="$HOME/code/me"                       # directory where repos live (triggers includeIf)

# Editor for commit messages and interactive rebase. Set "" to skip.
EDITOR_CMD="codium --wait"

# Clone repos onto a fresh machine. "off" skips the clone section (section 7)
# entirely. When "on", each "owner/repo" in CLONE_REPOS is cloned into CLONE_DIR,
# or fast-forward updated if already there. Cloning is over SSH and needs the
# key (section 2) already added to your GitHub account.
CLONE="off"
CLONE_DIR="$REAL_DIR"                          # clones land here; under the identity dir so they get the identity
CLONE_REPOS=()                                 # e.g. ("youruser/dotfiles" "youruser/notes")

## ─── Helpers ─────────────────────────────────────────────────────────────────

die() { printf '%s\n' "$*" >&2; exit 1; }
say() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }

backup_if_exists() {
    [[ -f "$1" ]] || return 0
    local backup
    backup="$1.backup.$(date +%Y%m%d-%H%M%S)"
    cp "$1" "$backup" || die "backup failed: $1"
    say "  backed up: $1 → $backup"
}

# Unset every existing includeIf.gitdir:*.path entry whose value matches TARGET.
# Used to clear stale entries when REAL_DIR changes between runs. The `|| true`
# on the git config call keeps the pipefail/-e combination from killing the
# script when there are no matches (which is the normal first-run case).
unset_stale_includes() {
    local target="$1" key value
    { git config --global --name-only --get-regexp '^includeIf\.gitdir:.*\.path$' 2>/dev/null || true; } \
        | while read -r key; do
            value=$(git config --global "$key" 2>/dev/null || true)
            if [[ "$value" == "$target" ]]; then
                git config --global --unset "$key" 2>/dev/null || true
                say "  unset stale include: $key"
            fi
        done
}

## ─── Sanity check ────────────────────────────────────────────────────────────

if [[ "$REAL_NAME" == "Your Name" || "$REAL_EMAIL" == "you@example.com" ]]; then
    die "Edit the CONFIG block at the top of this script before running."
fi

if ! command -v git &>/dev/null; then
    die "git not found. Install it first: sudo apt install git"
fi

if ! command -v ssh-keygen &>/dev/null; then
    die "ssh-keygen not found. Install it first: sudo apt install openssh-client"
fi

# git-revise backs the rv alias (section 4). Everything else works without
# it, so a missing one is a warning, not a stop.
if ! command -v git-revise &>/dev/null; then
    warn "git-revise not found; the rv alias needs it: sudo apt install git-revise"
fi

case "${CLONE:-off}" in
    on|off) ;;
    *) die "CLONE must be \"on\" or \"off\", got: $CLONE" ;;
esac

## ─── 1. Core global config ───────────────────────────────────────────────────

say "setting global git config..."
# No global identity by design. useConfigOnly makes a repo outside the
# includeIf directory fail loud rather than commit under whatever was lying
# around in global config. The unsets clear an identity left in the global
# config by any pre-v3 run.
git config --global user.useConfigOnly true
git config --global --unset user.name  || true
git config --global --unset user.email || true
git config --global init.defaultBranch main
if [[ -n "$EDITOR_CMD" ]]; then
    git config --global core.editor "$EDITOR_CMD"
else
    git config --global --unset core.editor 2>/dev/null || true
fi
git config --global diff.tool vimdiff
git config --global difftool.prompt false
git config --global pull.rebase true
git config --global push.autoSetupRemote true
git config --global fetch.prune true
git config --global rerere.enabled true
git config --global commit.verbose true
git config --global commit.template "$HOME/.gitmessage"
git config --global core.hooksPath "$HOME/.git-hooks"

## ─── 2. SSH key ──────────────────────────────────────────────────────────────

say "checking SSH key..."
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"

PRIMARY_KEY="$HOME/.ssh/id_ed25519"
readonly PRIMARY_KEY
if [[ -f "$PRIMARY_KEY" ]]; then
    say "  exists: $PRIMARY_KEY (not regenerated)"
    # -P "" unlocks only a key that has no passphrase, and never prompts.
    if ssh-keygen -y -P "" -f "$PRIMARY_KEY" >/dev/null 2>&1; then
        warn "  WARNING: $PRIMARY_KEY has no passphrase; anything running"
        warn "           as you can push and sign with it. Add one with:"
        warn "           ssh-keygen -p -f $PRIMARY_KEY"
    fi
else
    say "  generating: $PRIMARY_KEY (choose a passphrase when asked)"
    ssh-keygen -t ed25519 -f "$PRIMARY_KEY" -C "$REAL_EMAIL" -q
fi

say ""
say "Public key (copy and add at GitHub → Settings → SSH and GPG keys):"
say ""
cat "$PRIMARY_KEY.pub"
say ""

## ─── 3. Identity (directory-bound) ───────────────────────────────────────────

say "configuring identity (directory-bound under $REAL_DIR)..."

# 3a. ~/.gitconfig-me: this script owns this file entirely. The core.sshCommand
# line pins the SSH key, so repos under REAL_DIR use the right key even if a
# remote URL was never rewritten to a Host alias. Tags are signed with the SSH
# key in SSH-signature mode; commits are not, because one signed tag covers
# all history behind it (git_concepts § Signing). Each value is written via
# `git config --file`, which applies git's own escaping rules so a name or
# email containing #, ;, ", or \ survives the round trip.
backup_if_exists "$HOME/.gitconfig-me"
cat > "$HOME/.gitconfig-me" <<EOF
# Managed by git-setup. Identity, bound by includeIf to $REAL_DIR.
EOF
git config --file "$HOME/.gitconfig-me" user.name       "$REAL_NAME"
git config --file "$HOME/.gitconfig-me" user.email      "$REAL_EMAIL"
git config --file "$HOME/.gitconfig-me" user.signingkey "$HOME/.ssh/id_ed25519.pub"
git config --file "$HOME/.gitconfig-me" gpg.format      ssh
git config --file "$HOME/.gitconfig-me" tag.gpgsign     true
git config --file "$HOME/.gitconfig-me" core.sshCommand "ssh -i $HOME/.ssh/id_ed25519 -o IdentitiesOnly=yes"
git config --file "$HOME/.gitconfig-me" gpg.ssh.allowedSignersFile "$HOME/.ssh/allowed_signers"
chmod 600 "$HOME/.gitconfig-me"
say "  wrote ~/.gitconfig-me (tags SSH-signed, commits unsigned)"

# 3b. includeIf: directory-triggered identity switch. Strip any prior include
# first so changing REAL_DIR between runs doesn't leave the old entry behind.
mkdir -p "$REAL_DIR"
unset_stale_includes "$HOME/.gitconfig-me"
git config --global "includeIf.gitdir:${REAL_DIR}/.path" "$HOME/.gitconfig-me"
say "  repos under $REAL_DIR/ will use this identity automatically"

# 3c. allowed_signers: lets git verify-tag check your signatures. The script
# manages only the line for REAL_EMAIL: lines for other signers are kept, and
# an old line for REAL_EMAIL is replaced, so a changed key takes effect.
SIGNERS="$HOME/.ssh/allowed_signers"
backup_if_exists "$SIGNERS"
{
    if [[ -f "$SIGNERS" ]]; then
        ME="$REAL_EMAIL" awk '$1 != ENVIRON["ME"]' "$SIGNERS"
    fi
    printf '%s namespaces="git" %s\n' "$REAL_EMAIL" "$(cut -d' ' -f1,2 "$PRIMARY_KEY.pub")"
} > "$SIGNERS.tmp"
mv "$SIGNERS.tmp" "$SIGNERS"
say "  wrote $SIGNERS (git verify-tag checks your tags against it)"
say "  NOTE: signed tags show Verified on GitHub only after you also upload"
say "        the key as a Signing Key (see git_reference § Signing)."

## ─── 4. Aliases ──────────────────────────────────────────────────────────────

say "setting aliases..."

# Status
git config --global alias.s   "status"
git config --global alias.st  "status -s"

# Stage
git config --global alias.a   "add"
git config --global alias.ap  "add -p"

# Guard: refuse while a merge, rebase, cherry-pick or revert is in progress,
# or while any file still has unresolved conflicts. The aliases that stage
# everything (ca, caa, caam) and rv run it first; in those states, use plain
# git (git status shows how to finish or abort).
git config --global alias.idle  '!f() { for s in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do if [ -e "$(git rev-parse --git-path "$s")" ]; then echo "git idle: a merge, rebase, cherry-pick or revert is in progress; finish or abort it first (git status shows how)" >&2; return 1; fi; done; if [ -n "$(git ls-files -u)" ]; then echo "git idle: unresolved conflicts; fix each file and git add it first" >&2; return 1; fi; }; f'

# Commit
git config --global alias.c     "commit -m"
git config --global alias.ca    '!git idle && git add -A && git commit -m'
git config --global alias.caa   '!git idle && git add -A && git commit --amend --no-edit'
git config --global alias.caas  "commit --amend --no-edit"
git config --global alias.caam  '!f() { git idle && git add -A && git commit --amend -m "$1"; }; f'
git config --global alias.caams "commit --amend -m"
# cm: each argument becomes its own -m paragraph; no arguments opens the
# editor. bash -c because /bin/sh on Devuan is dash, which has no arrays.
git config --global alias.cm    "!bash -c 'args=(); for m in \"\$@\"; do args+=(-m \"\$m\"); done; git commit \"\${args[@]}\"' --"
git config --global alias.cf    "commit --fixup"
git config --global alias.cs    "commit -s"

# Revise older commits in memory with git-revise (git_reference § Revising
# older commits). git-revise does not check for a rebase in progress, so rv
# runs the guard first.
git config --global alias.rv    '!git idle && git revise'

# Retired: fold aliases that earlier versions set, plus amendto, a hand-made
# one with the same job; rv replaces them all.
for retired in fixto fixtos rewordto foldinto amendto; do
    if git config --global --get-all "alias.$retired" >/dev/null; then
        git config --global --unset-all "alias.$retired"
        say "  removed retired alias: $retired"
    fi
done

# Diff
git config --global alias.d   "diff"
git config --global alias.dc  "diff --cached"
git config --global alias.ds  "diff --stat"
git config --global alias.dw  "diff --word-diff"

# Log
git config --global alias.lg  "log --oneline --graph --all --decorate"
git config --global alias.ll  "log --oneline"
git config --global alias.lf  "log --follow --"

# Inspect
git config --global alias.last      "show --compact-summary HEAD"
git config --global alias.when      "log -1 --format=%cr --"
git config --global alias.lastfile  "log -1 --format='%cs %s' --"
git config --global alias.who       "shortlog -sne"
git config --global alias.aliases   "config --get-regexp '^alias\\.'"

# Identity (which identity is this repo using?)
git config --global alias.whoami    '!git config user.name; git config user.email; git config user.signingkey'

# Branch / switch
git config --global alias.br   "branch"
git config --global alias.co   "checkout"
git config --global alias.cob  "checkout -b"
git config --global alias.sw   "switch"
git config --global alias.swc  "switch -c"

# Restore
git config --global alias.unstage  "restore --staged"
git config --global alias.discard  "restore"

# Reset
git config --global alias.uncommit  "reset --soft HEAD~1"
git config --global alias.squash    "reset --soft"

# Rebase
git config --global alias.ria  "rebase -i --autosquash"
git config --global alias.rc   "rebase --continue"
git config --global alias.ra   "rebase --abort"

# Stash
git config --global alias.sl  "stash list"
git config --global alias.sa  "stash apply"
git config --global alias.sp  "stash pop"

# Push (safe force)
git config --global alias.pf  "push --force-with-lease --force-if-includes"

## ─── 5. Commit message template ──────────────────────────────────────────────

say "writing ~/.gitmessage..."
backup_if_exists "$HOME/.gitmessage"
cat > "$HOME/.gitmessage" <<'EOF'


# Subject (line 1): imperative, under 50 chars, no period.
# Blank line between subject and body.
# Body (line 3+): wrapped at 72. Explain why, not what.
# The diff below the scissors line shows what.
EOF

## ─── 6. Commit hook ──────────────────────────────────────────────────────────

say "writing prepare-commit-msg hook (conflict markers, rename safety)..."
mkdir -p "$HOME/.git-hooks"
backup_if_exists "$HOME/.git-hooks/prepare-commit-msg"
cat > "$HOME/.git-hooks/prepare-commit-msg" <<'EOF'
#!/bin/sh
# Git runs this hook on git commit and on git rebase --continue
# (pre-commit does not run there); --no-verify does not skip it.
#
# 1. Refuse a commit that adds conflict-marker lines. git add is what marks a
# conflict resolved; Git never looks inside the file. Only the opening and
# closing markers count, because a bare ======= line is also a Markdown
# heading underline.
if git diff --cached --no-color --no-ext-diff --no-textconv -U0 |
    grep -qE '^\+(<<<<<<<|>>>>>>>)( |$)'; then
  echo "Conflict markers are staged. Fix the file, git add it, and retry." >&2
  echo "To commit markers on purpose: git -c core.hooksPath=/dev/null commit ..." >&2
  exit 1
fi
#
# 2. Warn when the commit being prepared contains a rename with substantial
# content changes (similarity <60%). git log --follow loses a file across a
# rename below 50%; 60% leaves a margin. Detection runs at 30%, because at
# Git's default 50% the worst cases show as a delete plus an add and are
# never seen. $2 is the message source; $3 is HEAD when the source is an
# amend.
if [ "$2" = "commit" ] && [ "$3" = "HEAD" ]; then
  base=HEAD~1   # amend: the new commit's parent is HEAD's parent
else
  base=HEAD     # normal commit: the new commit's parent is HEAD
fi
git rev-parse --verify --quiet "$base" >/dev/null || exit 0   # initial commit: nothing to compare
if git diff --cached --find-renames=30% --name-status "$base" | grep '^R' | awk '{score=substr($1,2)+0; if(score<60) found=1} END{exit !found}'; then
  echo "Warning: this commit contains a rename with substantial content changes (similarity <60%). git log --follow may lose history. Rename in one commit; edit in a separate commit." >&2
fi
exit 0
EOF
chmod +x "$HOME/.git-hooks/prepare-commit-msg"

## ─── 7. Clone repos ──────────────────────────────────────────────────────────

# Optional, controlled by CLONE in the CONFIG block. Trusts GitHub's host key by
# pinning its published ed25519 key rather than a blind ssh-keyscan, so a
# man-in-the-middle on first contact cannot have its key silently accepted. Then
# clones or fast-forward updates each repo in CLONE_REPOS into CLONE_DIR.
# The clone pins the SSH key with -i and IdentitiesOnly, matching the
# core.sshCommand discipline used everywhere else, so a loaded agent
# cannot authenticate the clone as the wrong GitHub account.
if [[ "${CLONE:-off}" == "on" ]]; then
    say "cloning repos..."

    # GitHub's published ed25519 host key (docs.github.com, fingerprint
    # SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU). Appended only when
    # github.com is not already a known host, so re-runs do not stack duplicates.
    mkdir -p "$HOME/.ssh"
    chmod 700 "$HOME/.ssh"
    touch "$HOME/.ssh/known_hosts"
    chmod 600 "$HOME/.ssh/known_hosts"
    if ssh-keygen -F github.com >/dev/null 2>&1; then
        say "  github.com already in known_hosts (not re-added)"
    else
        printf '%s\n' 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' >> "$HOME/.ssh/known_hosts"
        say "  pinned github.com ed25519 host key"
    fi

    if [[ ${#CLONE_REPOS[@]} -eq 0 ]]; then
        warn "  CLONE is \"on\" but CLONE_REPOS is empty; nothing to clone"
    else
        mkdir -p "$CLONE_DIR"
        for repo in "${CLONE_REPOS[@]}"; do
            target="$CLONE_DIR/${repo##*/}"
            if [[ -d "$target/.git" ]]; then
                say "  updating $repo..."
                git -c core.sshCommand="ssh -i $PRIMARY_KEY -o IdentitiesOnly=yes" -C "$target" pull --ff-only \
                    || { warn "  pull not fast-forward for $repo; skipping"; continue; }
            else
                say "  cloning $repo..."
                git -c core.sshCommand="ssh -i $PRIMARY_KEY -o IdentitiesOnly=yes" clone "git@github.com:$repo.git" "$target" \
                    || { warn "  clone failed for $repo; skipping"; continue; }
            fi
        done
    fi
fi

## ─── 8. Prune old backups ────────────────────────────────────────────────────

# Remove timestamped backups older than 30 days, scoped to the four managed
# files this script touches. Keeps $HOME from accumulating decades of backups
# from re-runs; recent backups stay reachable for rollback.
say "pruning managed-file backups older than 30 days..."
for managed in \
    "$HOME/.gitconfig-me" \
    "$HOME/.ssh/allowed_signers" \
    "$HOME/.gitmessage" \
    "$HOME/.git-hooks/prepare-commit-msg"; do
    dir=$(dirname "$managed")
    base=$(basename "$managed")
    [[ -d "$dir" ]] || continue
    find "$dir" -maxdepth 1 -name "${base}.backup.*" -mtime +30 -delete 2>/dev/null || true
done

## ─── 9. Summary ──────────────────────────────────────────────────────────────

say ""
say "Done. Verify:"
say "  git config --list --show-origin   # everything Git sees, with source files"
say "  git aliases                       # your aliases"
say "  git whoami                        # name/email/signingkey for the current repo"
say ""
say "Identity (should resolve under $REAL_DIR):"
say "  mkdir -p $REAL_DIR && cd $REAL_DIR && git init _verify && cd _verify"
say "  git config user.email             # should be $REAL_EMAIL"
say "  cd .. && rm -rf _verify"
say ""
say "Fail-loud check (a repo outside $REAL_DIR MUST error):"
say "  cd /tmp && git init _nowhere && cd _nowhere"
say "  git commit --allow-empty -m test  # should fail: Author identity unknown"
say "  cd .. && rm -rf _nowhere"
if [[ "${CLONE:-off}" == "on" && ${#CLONE_REPOS[@]} -gt 0 ]]; then
    say ""
    say "Cloned or updated under $CLONE_DIR:"
    for repo in "${CLONE_REPOS[@]}"; do
        say "  ${repo##*/}  ($repo)"
    done
fi

## ─── 10. Overrides ───────────────────────────────────────────────────────────

# Last, so nothing scrolls past it: anything that would quietly override this
# setup. The script reports these rather than fixing them: it can't change the
# shell it was started from, and it doesn't own your shell startup files or
# your repos' own config.
overrides=()

# a. Variables. The author and committer ones override the directory-bound
# identity in every repo, GIT_SSH_COMMAND the pinned key, GIT_EDITOR the editor.
override_vars='GIT_AUTHOR_NAME|GIT_AUTHOR_EMAIL|GIT_COMMITTER_NAME|GIT_COMMITTER_EMAIL|GIT_SSH_COMMAND|GIT_EDITOR'
for var in ${override_vars//|/ }; do
    [[ -n "${!var+set}" ]] && overrides+=("$var is set in this shell")
done
for rc in "$HOME/.bashrc" "$HOME/.bash_aliases" "$HOME/.profile" "$HOME/.bash_profile"; do
    [[ -f "$rc" ]] || continue
    while IFS= read -r hit; do
        overrides+=("$rc:$hit")
    done < <(grep -nE "^[[:space:]]*(export[[:space:]]+)?($override_vars)=" "$rc")
done

# b. Other hooks. core.hooksPath makes Git run every executable hook in
# ~/.git-hooks in every repo, not only the one section 6 writes.
for hook in "$HOME/.git-hooks"/*; do
    name=${hook##*/}
    [[ -f "$hook" && -x "$hook" && "$name" != *.* && "$name" != prepare-commit-msg ]] || continue
    overrides+=("$hook also runs in every repo")
done

# c. A repo's own config beats the include file. Looks four levels down, so
# repos up to three folders below REAL_DIR are found.
while IFS= read -r gitdir; do
    repo=${gitdir%/.git}
    while IFS= read -r key; do
        overrides+=("$repo sets $key; to remove: git -C '$repo' config --local --unset $key")
    done < <(git -C "$repo" config --local --name-only --get-regexp '^(user\.(name|email|signingkey)|core\.hookspath|core\.sshcommand)$' 2>/dev/null)
done < <(find "$REAL_DIR" -maxdepth 4 -name .git -prune -print 2>/dev/null)

say ""
if [[ ${#overrides[@]} -gt 0 ]]; then
    warn "WARNING: these override the setup. Remove any that aren't on purpose;"
    warn "         after removing a variable, open a new terminal."
    for o in "${overrides[@]}"; do
        warn "  $o"
    done
else
    say "Overrides: none found."
fi

Git history delete

Script to permanently purge files from a git repo

The script below removes files you’ve already deleted from every commit in a Git repo, so they’re gone from its history and not just from the latest version. Use it when a file has to vanish completely, like private notes in a repo you’re about to make public. It skips anything still tracked on a branch or tag, so delete the file and commit that first. It wraps git filter-repo and rewrites history: every commit from the first one with that file gets a new hash, and you force-push afterwards. If the file held a password or a key, change that first; purging doesn’t undo a leak, and Never commit secrets explains why. For details, see Purging files from history in Git reference.

  1. Verify script: read it through before you run it.
  2. Copy and save script as gitdel.sh.
  3. Back up the repo’s history: tar -czf ~/myrepo-git.tar.gz -C ~/projects/myrepo .git. It prints nothing. If the purge goes wrong, this is your way back; the branch tips the script prints aren’t, because filter-repo deletes the old commits as it finishes.
  4. Do a dry run: bash gitdel.sh ~/projects/myrepo --dry-run. It lists the files it would purge and ends with Dry-run complete. No changes made. If something you want to keep is on the list, stop here. If it stops early because git-filter-repo isn’t installed or you have uncommitted changes, it says what to do.
  5. Run bash gitdel.sh ~/projects/myrepo --save-list ~/Downloads/purge_log.txt. It prints the same list, a WARNING block, and Proceed? This rewrites history and cannot be undone. (y/n):. Type y. The filter-repo lines that follow are normal, including a NOTICE that it removed origin. It ends with Done. Git history has been rewritten. and the git remote add and git push --force commands for your repo.
  6. Run those commands. The branch push shows (forced update); the tag push may only say Everything up-to-date, which is fine. Anyone else with a copy of the repo needs to clone it again.
#!/usr/bin/env bash
set -euo pipefail

# Git delete, v7

## ─── Usage ────────────────────────────────────────────────────────────────────
## To permanently purge files from a git repo in say ~/projects/myrepo, run in terminal:
##   bash gitdel.sh <repo-path> [file-list.txt] [--dry-run] [--save-list <file>] [--yes]
##   example: bash gitdel.sh ~/projects/myrepo --save-list ~/Downloads/purge_log.txt
##
## Without a file list: auto-detects all deleted files in history (all refs).
## With a file list:    purges those files and their other names (see
##                      Renames); same validation rules apply.
## Renames: if a deleted file was renamed, all historical names back to its
##          original are purged. If its final name is still tracked, the
##          entire chain is skipped. Where another file reused a name in the
##          chain, or the chain loops, gitdel warns or skips instead of
##          guessing; check those by hand.
## --dry-run:           shows what would be purged, touches nothing.
## --save-list <file>:  saves the final purge list to a file.
## --yes / -y:          skip the interactive confirm prompt. Use only in
##                      non-interactive contexts where you have already
##                      reviewed the dry-run output.

## ─── Flow ─────────────────────────────────────────────────────────────────────
## 1. Parse arguments into REPO_PATH, INPUT, DRY_RUN, SAVE_LIST, ASSUME_YES.
## 2. Validate dependencies, repo state, and working tree cleanliness.
## 3. Capture remote URLs before filter-repo wipes them.
## 4. Detect submodule paths to skip them during purge.
## 5. Build a rename map in both directions (old -> new, new -> old) across all branches.
## 6. Build a lookup of all currently tracked files across every ref.
## 7. Collect files to purge: from input list or by scanning deleted files in history.
## 8. Validate each candidate: skip tracked, on-disk, submodule, or live-renamed files.
## 9. For renamed files whose final destination is also gone, resolve and purge the full chain, earlier names included.
## 10. Report skipped files, show purge list, optionally save it.
## 11. In dry-run mode, exit here.
## 12. Confirm with user (unless --yes), record pre-rewrite ref tips, run filter-repo, print remote re-add and force-push instructions.

## ─── Scope ────────────────────────────────────────────────────────────────────
## Deletions and renames are scanned across all refs (--all). TRACKED_SET is
## built from the union of every ref's tree, so a file live on any branch,
## tag, or stash is protected from purge.
##
## Merge-only renames (renames recorded only in merge commits) are invisible
## to the rename scan because --no-merges skips them. If you know a file was
## renamed during conflict resolution in a merge commit, list those paths in
## the file list (the second argument).
##
## Rename-similarity threshold is -M10%, which catches low-similarity renames
## at the cost of occasional false positives on repos with many near-identical
## files (generated code, lockfiles). A false positive links two unrelated
## files. If the later one is still tracked, the earlier one is skipped
## (under-purge, the safer direction). If both are deleted, purging either
## one also lists the other; the purge list shows it before you confirm.
##
## Path scans run under core.quotePath=false so that non-ASCII paths are
## emitted verbatim rather than C-quoted; without this a tracked non-ASCII
## file could be misread as untracked and purged, and the purge list handed
## to filter-repo could match nothing. One residual limitation remains: a
## path containing a literal tab or newline byte cannot be round-tripped
## through the newline-delimited scan and is not handled; such paths are
## vanishingly rare and must be purged manually.

## ─── Argument parsing ─────────────────────────────────────────────────────────

REPO_PATH=""
INPUT=""
DRY_RUN=false
SAVE_LIST=""
ASSUME_YES=false

while [[ $# -gt 0 ]]; do
    case "$1" in
        --dry-run)
            DRY_RUN=true
            shift
            ;;
        --save-list)
            [[ $# -lt 2 ]] && { echo "Error: --save-list requires an argument."; exit 1; }
            SAVE_LIST="$2"
            shift 2
            ;;
        --yes|-y)
            ASSUME_YES=true
            shift
            ;;
        -*)
            echo "Unknown option: $1"
            echo "Usage: bash gitdel.sh <repo-path> [file-list.txt] [--dry-run] [--save-list <file>] [--yes]"
            exit 1
            ;;
        *)
            if [[ -z "$REPO_PATH" ]]; then
                REPO_PATH="$1"
            elif [[ -z "$INPUT" ]]; then
                INPUT="$1"
            else
                echo "Unexpected argument: $1"
                exit 1
            fi
            shift
            ;;
    esac
done

if [[ -z "$REPO_PATH" ]]; then
    echo "Usage: bash gitdel.sh <repo-path> [file-list.txt] [--dry-run] [--save-list <file>] [--yes]"
    exit 1
fi

REPO_PATH="$(realpath "$REPO_PATH")"
[[ -n "$INPUT" ]] && INPUT="$(realpath "$INPUT")"

if [[ -n "$SAVE_LIST" ]]; then
    SAVE_DIR="$(dirname "$SAVE_LIST")"
    [[ ! -d "$SAVE_DIR" ]] && { echo "Error: --save-list parent directory does not exist: $SAVE_DIR"; exit 1; }
    SAVE_LIST="$(realpath "$SAVE_DIR")/$(basename "$SAVE_LIST")"
fi

## ─── Dependency check ─────────────────────────────────────────────────────────

if ! command -v git-filter-repo &>/dev/null; then
    echo "git-filter-repo not found. Install it with:"
    echo "  sudo apt install git-filter-repo"
    exit 1
fi

## ─── Repo validation ──────────────────────────────────────────────────────────

if [[ ! -d "$REPO_PATH" ]]; then
    echo "Repo path not found or not a directory: $REPO_PATH"
    exit 1
fi

cd "$REPO_PATH"

if ! git rev-parse --git-dir &>/dev/null; then
    echo "Not inside a git repository: $REPO_PATH"
    exit 1
fi

GIT_ROOT="$(git rev-parse --show-toplevel)"
if [[ "$(pwd -P)" != "$GIT_ROOT" ]]; then
    echo "Switching to repo root: $GIT_ROOT"
    cd "$GIT_ROOT"
fi

git diff --quiet          || { echo "Unstaged changes detected. Commit or stash them first."; exit 1; }
git diff --cached --quiet || { echo "Staged changes detected. Commit or stash them first."; exit 1; }

echo "Repo: $(pwd -P)"
[[ "$DRY_RUN" == true ]] && echo "(dry-run mode — no changes will be made)"
echo ""

## ─── Capture remote URLs before filter-repo removes them ─────────────────────

declare -A REMOTE_URLS=()
while IFS= read -r remote; do
    url="$(git remote get-url "$remote" 2>/dev/null || true)"
    if [[ -z "$url" ]]; then
        echo "WARNING: remote '$remote' has no URL configured; omitting it from re-add instructions."
        continue
    fi
    REMOTE_URLS["$remote"]="$url"
done < <(git remote)

if [[ ${#REMOTE_URLS[@]} -eq 0 ]]; then
    echo "WARNING: No remote detected. If something goes wrong, history cannot be recovered."
    echo "Consider pushing to a backup remote before proceeding."
    echo ""
fi

## ─── Submodule detection ──────────────────────────────────────────────────────

declare -A SUBMODULE_PATHS=()
if [[ -f ".gitmodules" ]]; then
    ## || [[ -n "$line" ]] keeps a last line that has no newline after it;
    ## without it, read reports end of file and the loop drops that line.
    while IFS= read -r line || [[ -n "$line" ]]; do
        if [[ "$line" =~ ^[[:space:]]*path[[:space:]]*=[[:space:]]*(.+)$ ]]; then
            SUBMODULE_PATHS["${BASH_REMATCH[1]}"]=1
        fi
    done < .gitmodules
fi

## ─── Build rename map (across all branches) ───────────────────────────────────
## RENAMED_FROM: old_path -> immediate new_path; RENAMED_TO: the reverse.
## One hop each. RENAME_OUT / RENAME_IN count the distinct names a path was
## renamed to / from; above 1, two files shared that name at different times.
## -F'\t' is required — paths can contain spaces; default awk splitting breaks them.
## -M10% lowers the similarity threshold from the default 50% so that low-similarity
## renames are still detected. This may produce occasional false positives on repos
## with many near-identical files, but missing a rename is worse than a false positive
## here because an undetected rename leaves stale history under the old name.

echo "Scanning rename history..."
declare -A RENAMED_FROM=()
declare -A RENAMED_TO=()
declare -A RENAME_SEEN=()   # old<TAB>new pairs already counted
declare -A RENAME_OUT=()
declare -A RENAME_IN=()

while IFS=$'\t' read -r old new; do
    [[ -z "$old" || -z "$new" ]] && continue
    RENAMED_FROM["$old"]="$new"
    RENAMED_TO["$new"]="$old"
    pair="$old"$'\t'"$new"
    if [[ ! -v RENAME_SEEN["$pair"] ]]; then
        RENAME_SEEN["$pair"]=1
        RENAME_OUT["$old"]=$(( ${RENAME_OUT["$old"]:-0} + 1 ))
        RENAME_IN["$new"]=$(( ${RENAME_IN["$new"]:-0} + 1 ))
    fi
done < <(git -c core.quotePath=false log --all --no-merges --pretty=format: --diff-filter=R -M10% --name-status \
         | awk -F'\t' '/^R[0-9]*\t/{print $2"\t"$3}')

## resolve_rename_chain <path>
##
## Writes <path>'s rename chain to stdout, one name per line, oldest first:
## the earlier names found by walking RENAMED_TO back, then <path>, then the
## later names found by walking RENAMED_FROM forward. The last line is the
## final name.
##
## Requires bash 4.3+. The binding constraint is the negative array index
## ${chain[-1]} used below, which bash introduced in 4.3; the 'declare -A'
## scoping and '-v' key tests this function also relies on are older (4.0
## and 4.2 respectively). Debian and Devuan stable ship bash 5.x, so this
## is not a concern in practice, but do not run on bash < 4.3.
##
## Cycle detection: keeps a visited associative array; if a name reappears
## the chain is corrupt — emit an error to stderr and return non-zero, and
## the caller skips the file.
##
## Reused names: a count above 1 in RENAME_IN or RENAME_OUT means two files
## shared a name at different times. Walking back, the walk stops before
## such a name and warns, so the earlier names are kept (under-purge, the
## safe direction). Walking forward, it cannot tell which name the file
## ended under, so it returns non-zero, as for a cycle.

resolve_rename_chain() {
    local start="$1" current prev i
    local back=()
    declare -A visited=()
    visited["$start"]=1

    current="$start"
    while [[ -v RENAMED_TO["$current"] ]]; do
        prev="${RENAMED_TO[$current]}"
        if (( ${RENAME_IN["$current"]:-0} > 1 || ${RENAME_OUT["$prev"]:-0} > 1 )); then
            echo "WARNING: rename history before '$current' is ambiguous; earlier names are not purged. Check them by hand." >&2
            break
        fi
        if [[ -v visited["$prev"] ]]; then
            echo "ERROR: rename cycle detected involving: $prev" >&2
            return 1
        fi
        visited["$prev"]=1
        back+=("$prev")
        current="$prev"
    done

    for (( i=${#back[@]}-1; i>=0; i-- )); do
        echo "${back[i]}"
    done

    current="$start"
    echo "$current"
    while [[ -v RENAMED_FROM["$current"] ]]; do
        if (( ${RENAME_OUT["$current"]:-0} > 1 )); then
            echo "ERROR: '$current' was renamed to more than one name; cannot tell which one is current." >&2
            return 1
        fi
        current="${RENAMED_FROM[$current]}"
        if [[ -v visited["$current"] ]]; then
            echo "ERROR: rename cycle detected involving: $current" >&2
            return 1
        fi
        visited["$current"]=1
        echo "$current"
    done
}

## ─── Build tracked-files lookup (across all refs) ─────────────────────────────
## git ls-files only lists HEAD, which is narrower than what filter-repo
## rewrites (all refs). Build TRACKED_SET from every ref's tree so a file
## live on any branch, tag, or stash is protected from purge.

echo "Building tracked-files lookup across all refs..."
declare -A TRACKED_SET=()
while IFS= read -r f; do
    [[ -n "$f" ]] && TRACKED_SET["$f"]=1
done < <(
    git for-each-ref --format='%(refname)' | while IFS= read -r ref; do
        git -c core.quotePath=false ls-tree -r --name-only "$ref" 2>/dev/null || true
    done | sort -u
)

## ─── Collect files to purge ───────────────────────────────────────────────────

declare -A PURGE_SET=()   # used for deduplication
PURGE=()

## Single associative array: skipped_renamed["old_path"]="final_path"
declare -A SKIPPED_TRACKED_SET=()
SKIPPED_TRACKED=()
declare -A SKIPPED_EXISTS_SET=()
SKIPPED_EXISTS=()
declare -A SKIPPED_SUBMODULE_SET=()
SKIPPED_SUBMODULE=()
declare -A SKIPPED_RENAMED=()   # old_path -> live final destination

add_to_purge() {
    local name="$1"
    if [[ ! -v PURGE_SET["$name"] ]]; then
        PURGE_SET["$name"]=1
        PURGE+=("$name")
    fi
}

validate_and_collect() {
    local f="$1"

    if [[ "$f" == /* ]]; then
        echo "ERROR: absolute path not supported: $f"
        exit 1
    fi

    if [[ -v TRACKED_SET["$f"] ]]; then
        if [[ ! -v SKIPPED_TRACKED_SET["$f"] ]]; then
            SKIPPED_TRACKED_SET["$f"]=1
            SKIPPED_TRACKED+=("$f")
        fi
        return
    fi

    if [[ -e "$f" ]]; then
        if [[ ! -v SKIPPED_EXISTS_SET["$f"] ]]; then
            SKIPPED_EXISTS_SET["$f"]=1
            SKIPPED_EXISTS+=("$f")
        fi
        return
    fi

    if [[ -v SUBMODULE_PATHS["$f"] ]]; then
        if [[ ! -v SKIPPED_SUBMODULE_SET["$f"] ]]; then
            SKIPPED_SUBMODULE_SET["$f"]=1
            SKIPPED_SUBMODULE+=("$f")
        fi
        return
    fi

    if [[ -v RENAMED_FROM["$f"] || -v RENAMED_TO["$f"] ]]; then
        local chain_out
        local chain=()
        ## Command substitution, not < <(...): a process substitution's exit
        ## status is lost, so a cycle would go unnoticed.
        if ! chain_out="$(resolve_rename_chain "$f")"; then
            echo "ERROR: skipping '$f' due to an ambiguous rename history. Inspect manually." >&2
            return
        fi
        mapfile -t chain <<< "$chain_out"

        local final="${chain[-1]}"

        if [[ -v TRACKED_SET["$final"] ]]; then
            SKIPPED_RENAMED["$f"]="$final"
        else
            for name in "${chain[@]}"; do
                if [[ -v TRACKED_SET["$name"] ]]; then
                    echo "WARNING: rename chain name '$name' is currently tracked; skipping that name only." >&2
                else
                    add_to_purge "$name"
                fi
            done
        fi
        return
    fi

    add_to_purge "$f"
}

if [[ -n "$INPUT" ]]; then
    echo "Reading file list from: $INPUT"
    ## || [[ -n "$line" ]] keeps a last line that has no newline after it;
    ## without it, read reports end of file and the loop drops that line.
    while IFS= read -r line || [[ -n "$line" ]]; do
        [[ -z "$line" || "$line" == \#* ]] && continue
        f="${line#./}"
        f="${f%$'\r'}"
        validate_and_collect "$f"
    done < "$INPUT"
else
    echo "Scanning git history for deleted files (all branches)..."
    while IFS= read -r line; do
        [[ -n "$line" ]] && validate_and_collect "$line"
    done < <(git -c core.quotePath=false log --all --no-merges --pretty=format: --name-only --diff-filter=D | sort -u)
fi

## ─── Report skipped files ─────────────────────────────────────────────────────

if (( ${#SKIPPED_TRACKED[@]} > 0 )); then
    echo "WARNING: ${#SKIPPED_TRACKED[@]} file(s) skipped — currently tracked on some ref:"
    printf '  %s\n' "${SKIPPED_TRACKED[@]}"
fi

if (( ${#SKIPPED_EXISTS[@]} > 0 )); then
    echo "WARNING: ${#SKIPPED_EXISTS[@]} file(s) skipped — exist on disk but not tracked:"
    printf '  %s\n' "${SKIPPED_EXISTS[@]}"
fi

if (( ${#SKIPPED_SUBMODULE[@]} > 0 )); then
    echo "WARNING: ${#SKIPPED_SUBMODULE[@]} submodule path(s) skipped — handle these manually:"
    printf '  %s\n' "${SKIPPED_SUBMODULE[@]}"
fi

if (( ${#SKIPPED_RENAMED[@]} > 0 )); then
    echo "INFO: ${#SKIPPED_RENAMED[@]} file(s) skipped — renamed in history, live destination is tracked:"
    for old in "${!SKIPPED_RENAMED[@]}"; do
        echo "  $old  ->  ${SKIPPED_RENAMED[$old]}"
    done
fi

if (( ${#PURGE[@]} == 0 )); then
    echo "No files to purge after validation."
    exit 0
fi

## ─── Show purge list ──────────────────────────────────────────────────────────

echo ""
echo "Files to purge from history (${#PURGE[@]} total):"
printf '  %s\n' "${PURGE[@]}"
echo ""

## ─── Optionally save the list ─────────────────────────────────────────────────

if [[ -n "$SAVE_LIST" ]]; then
    printf '%s\n' "${PURGE[@]}" > "$SAVE_LIST"
    echo "File list saved to: $SAVE_LIST"
fi

## ─── Dry-run exit ─────────────────────────────────────────────────────────────

if [[ "$DRY_RUN" == true ]]; then
    echo "Dry-run complete. No changes made."
    exit 0
fi

## ─── Confirm and execute ──────────────────────────────────────────────────────

echo ""
echo "WARNING: git filter-repo --force bypasses the fresh-clone check."
echo "  - Stashes will be discarded."
echo "  - Other worktrees pointing at this repo will break."
echo "  - Remote-tracking refs will be cleared (by design)."
echo "  - Reflog entries may be expired by subsequent gc."
echo "If this repo is not a throwaway clone dedicated to this purge, stop now."
echo ""

if [[ "$ASSUME_YES" == true ]]; then
    echo "Proceeding without prompt (--yes specified)."
else
    printf "Proceed? This rewrites history and cannot be undone. (y/n): " >/dev/tty
    read -r CONFIRM </dev/tty
    [[ "$CONFIRM" == "y" ]] || { echo "Aborted."; exit 0; }
fi

TMPFILE="$(mktemp)"
trap 'rm -f "$TMPFILE"' EXIT
printf '%s\n' "${PURGE[@]}" > "$TMPFILE"

## Recovery anchors: filter-repo rewrites every ref, so record the tip of
## each ref (not just HEAD) before force-pushing. If the rewrite is wrong,
## `git update-ref <refname> <sha>` restores any ref to its pre-rewrite
## tip without depending on reflog retention.
echo "Pre-rewrite ref tips (record these; recover any ref with: git update-ref <refname> <sha>):"
git for-each-ref --format='  %(refname) %(objectname)' refs/heads refs/tags
echo ""

## Pre-clean filter-repo metadata directory. A prior gitdel run (or any
## prior filter-repo run) leaves state under .git/filter-repo/. If that
## directory persists for more than a day and we hit filter-repo's
## continuation prompt, answering Y to it makes filter-repo try to chain
## off the prior metadata, which can crash in `_record_metadata` with
## `FileNotFoundError: ...first-changed-commits` after the rewrite has
## already been written to refs. Removing the directory here ensures no
## continuation prompt fires and no chaining attempts occur.
rm -rf "$GIT_ROOT/.git/filter-repo"

git filter-repo --invert-paths --force --paths-from-file "$TMPFILE"

echo ""
echo "Done. Git history has been rewritten."
echo "Verify a path was purged with: git log --all --oneline -- <path>  (should print nothing)."
echo "Note: remotes and remote-tracking refs are cleared by filter-repo by design."
echo ""

if [[ ${#REMOTE_URLS[@]} -gt 0 ]]; then
    echo "Re-add your remote(s) and force-push:"
    ## Sort remotes for deterministic output
    for remote in $(printf '%s\n' "${!REMOTE_URLS[@]}" | sort); do
        echo "  git remote add $remote ${REMOTE_URLS[$remote]}"
        echo "  git push --force --all $remote"
        echo "  git push --force --tags $remote"
    done
    echo ""
fi

echo "WARNING: Any automated sync scripts, CI pipelines, or backup jobs pointed"
echo "at this remote will fail until you have manually force-pushed. They will"
echo "not auto-recover — trigger them manually after the force-push completes."

Security Overview

Secure your system; it is the prudent thing to do. Before the steps, the security landscape maps what you are defending and how far the climb goes. It’s a long journey, but the guides below will help you get through the steps in order. They place concepts before procedures and climb slowly, each reducing more attack surface. The early ones (OS, encryption) are the foundation the rest assume. You don’t have to reach the summit on day one, but you can start climbing today.

  1. Replace the OS. Compare distros, leave Windows or Mac, pick one, migrate. The largest single cut to your attack surface, and the foundation every guide above it assumes.
  2. Encrypt. Disk and file encryption.
  3. Secure messaging. Signal at the base, up through federated, Nostr-rooted, P2P, off-grid, and email.
  4. Sovereign transport. VPN, mesh, overlay, Tor, censorship-evasion, off-grid radio.
  5. Vet documents. Scanning and metadata hygiene for files you receive, before you open them.
  6. Dedicated hardware. A desktop built from parts, Linux or coreboot laptops, hardware tokens; raises the floor the apex guides stand on.
  7. Harden the whole workstation. The desktop apex: LUKS, VM compartmentalisation, USBGuard, nftables, kernel hardening, encrypted DNS. Installed per the full-disk encryption guide.

Cross-cutting concerns

Things that don’t belong cleanly to any one layer but matter to the overall stack.

Hardware tokens

A small USB device that holds cryptographic keys and performs operations with them without ever releasing the keys to the host. The key material lives inside tamper-resistant hardware; an attacker who compromises the laptop cannot extract the keys without physically having the token.

Uses:

  • SSH authentication. SSH keys live in the token. ssh calls into the token via FIDO or PKCS#11 to sign authentication challenges. The host never has the private key. Lost or stolen laptop is no longer “and now they have my SSH keys”, they have a laptop with no SSH access until they steal the token too.
  • GPG / OpenPGP. Same idea for GPG keys (encryption, signing, certification). YubiKey and Nitrokey both support OpenPGP keys natively; the gpg-agent integration is straightforward on Devuan.
  • Sudo authentication / PAM. pam_u2f lets you require a token tap for sudo, sudo-rights operations, or login. Tightens the local-attacker threat model substantially.
  • 2FA for online accounts. WebAuthn / FIDO2 for every service that supports it. Strictly better than TOTP because the token signs the origin domain, so a phishing site can’t replay the second factor.
  • LUKS unlock. Niche but real: a token can hold a keyfile that unlocks LUKS, used in combination with a passphrase.

The major options:

  • YubiKey. The dominant brand. YubiKey 5 series supports FIDO2, OpenPGP, PIV, OATH, OTP, basically everything. Closed-source firmware that is not updatable: if a vulnerability is found in the firmware (as in the September 2024 EUCLEAK side-channel disclosure against YubiKey 5), you replace the device rather than apply a patch. Available everywhere; works on every platform. FIDO Level 2 Certification (higher than the open-source competitors’ current certifications).
  • Nitrokey. German company. The Nitrokey 3 series runs fully open-source firmware called Trussed (Rust-based, jointly developed with SoloKeys). Older Nitrokey models (Pro, Start) are partially open, the underlying smartcard chips have proprietary firmware. For users who care about firmware auditability, the Nitrokey 3 is the meaningful pick; for users who care less about auditability and more about feature breadth, YubiKey wins.
  • Solo / SoloKey. Open-source hardware and firmware. First open-source FIDO2 security key, launched 2018 via Kickstarter. Solo 2 series shares the Trussed firmware framework with Nitrokey 3. FIDO2-focused, no OpenPGP or OATH; simpler scope.
  • OnlyKey. Open-source firmware. Acts as a USB keyboard and types stored passwords on a button press. Six physical buttons on the device plus a PIN entered on the device itself. Stores up to 24 accounts on-device. Different model from the others (password manager on hardware) rather than just a FIDO key.

Recommendation: a Nitrokey 3 (open-source firmware) for users who care about that property; a YubiKey 5 for users who want the broadest software support. Always buy two, one daily, one in a safe deposit box, both registered to the same accounts. A single hardware token that gets lost or destroyed locks you out of everything it protected. Don’t repeat that mistake; everyone tells the same story about it.

Integration on Devuan is straightforward:

sudo apt install libpam-u2f yubikey-manager

Configuration depends on what you’re protecting; the Arch Wiki entries for pam_u2f, gpg-agent, and OpenSSH FIDO are the practical references.

Heads coreboot

Open-source firmware that replaces the proprietary UEFI/BIOS on supported hardware. Built on coreboot; uses a YubiKey or similar token to verify that the boot path hasn’t been tampered with.

What it defends against:

The evil-maid attack. An attacker with physical access to a powered-off laptop modifies the firmware or bootloader. On a normal laptop, the user has no way to detect this; they boot, type the LUKS passphrase, and the modified firmware captures it. On a Heads-equipped laptop, the firmware itself is measured at boot, the measurement is checked against a signature on the token, mismatch triggers a visible alert before the user types anything sensitive.

The cost:

Hardware-specific. Heads runs on a specific list of supported machines, mostly older ThinkPads (X230, T440, T530, X1 Carbon up to specific generations) and some Purism Librem and Insurgo PrivacyBeast models. Flashing Heads is real work; failures can brick the laptop. There are vendors (Insurgo, Mullvad’s PrivacyBeast partnership) that sell Heads-preinstalled hardware at a premium, which is the cleaner path for most users.

Heads also has trade-offs in daily use: firmware updates require re-signing the boot measurement; kernel updates require re-signing too. Recovery from a forgotten token PIN or a corrupted token is non-trivial. For users with the threat model that justifies it, the trade-off is correct; for users without that threat model, Heads is overkill and the recovery friction is worse than the protection is worth.

Where to read more: osresearch.net for the project itself, plus Insurgo and Mullvad for purchase options.

Tor versus VPN versus self-hosted mesh

A frequent question. The short answer: three different things often grouped under “VPN,” each with a different threat model.

  1. Commercial VPN. Single-hop tunnel to one company that knows who you are. Replaces your ISP with one trusted company. The “VPN for privacy” frame; structurally weaker than Tor and not an anonymity tool.
  2. Self-hosted or mesh VPN. WireGuard between machines you control; Tailscale-style mesh with the coordinator either centralised or self-hosted (Headscale, NetBird). Not an anonymity tool; the right answer for connecting your own devices over hostile networks.
  3. Tor. Multi-hop volunteer-run onion network. The actual answer for anonymity.

For the commercial-VPN versus Tor question:

A commercial VPN encrypts your traffic between your device and the VPN provider. From the VPN’s exit, your traffic continues to its destination over normal internet, unencrypted at that hop unless the destination uses HTTPS (which it usually does in 2026). What a commercial VPN gets you:

  • Your ISP sees encrypted traffic to the VPN, not your destinations.
  • The VPN sees your traffic and your destinations.
  • The destinations see the VPN’s IP, not yours.

The commercial-VPN threat model is “I don’t trust my ISP.” The VPN replaces the ISP with a single company that you trust more (or that you think you trust more). If the VPN is compromised, logs are subpoenaed, or the VPN itself is hostile, all your traffic is exposed. This is not a hypothetical: VPN companies have been compromised, have been compelled to log, and have been acquired by surveillance-adjacent parents.

Tor encrypts your traffic and routes it through three unrelated volunteer-run nodes, each of which only knows one hop. The entry guard knows who you are but not what you’re doing; the middle relay knows nothing useful; the exit knows what you’re doing but not who you are. For any single party to deanonymise you, they need to control or observe both your guard and your exit, which is exponentially harder than compromising a single VPN.

Tor’s threat model is “I don’t trust any single party with both who I am and what I’m doing.” It is structurally stronger than any commercial VPN can be.

For the self-hosted and mesh case:

WireGuard between machines you control gives you an encrypted tunnel with no third party. Tailscale, NetBird, and ZeroTier add convenient mesh networking on top, with a central coordinator that handles peer discovery and access control. Self-hosting that coordinator (Headscale for Tailscale clients; NetBird in self-hosted mode) closes the structural capture-risk that the hosted-coordinator products introduce. None of this is an anonymity tool; it’s an “encrypted private network between your devices” tool.

The Nostr-rooted frontier is nostr-vpn plus its underlying FIPS protocol, a sovereignty-aligned mesh where your identity is a Nostr keypair and coordination happens over public Nostr relays. Alpha-grade in 2026; the architecture is what to learn from now, deployment comes later.

The exception worth naming:

A commercial VPN may make sense as a transport for getting to Tor when your ISP blocks Tor directly. User → VPN → Tor → internet. This is the “Tor over VPN” configuration and is the only legitimate use of a VPN alongside Tor that the Whonix project documents. It is advanced, not leak-tested at the level of the base Whonix setup, and is unnecessary unless your ISP actually blocks Tor. For the more general case of accessing Tor in a censored environment, Tor’s pluggable transports (Snowflake, Meek, obfs4) and bridges are the project’s own answer.

For the typical user with no specific reason: use Tor (via Tor Browser or Whonix) when you need anonymity; use WireGuard or Headscale when you need a private network between your own devices; don’t bother with a commercial VPN unless you have a narrow specific reason (geographic bypass, hostile-ISP bypass). The whole “VPN for privacy” marketing of the 2010s and 2020s sold a weaker product to people who wanted stronger.

For the full landscape of VPN, mesh, overlay, anonymity, and off-grid networking options, see Choosing networking tools.

Browser hardening

The browser is the largest attack surface most users have. Several real options.

  • Tor Browser. Firefox-based, configured by the Tor Project, ships through Tor. The right choice for sensitive sessions and the only choice for genuine browser-level anonymity. Slow (Tor is slow), incompatible with sites that block Tor exits (Cloudflare-protected sites in particular). Not a daily-driver browser.
  • LibreWolf. Firefox without telemetry, configured for privacy by default, with the worst Firefox-tracking-defaults flipped. Available in Devuan or via Flatpak. The right default daily-driver browser for a privacy-conscious user. Comes with uBlock Origin and reasonable defaults.
  • Mullvad Browser. Firefox-based, made by the Mullvad VPN company in collaboration with the Tor Project. Designed to give Tor Browser’s anti-fingerprinting properties without Tor itself; designed to be used over a VPN for users who want Tor-level browser anonymity without Tor-level speed costs. A reasonable middle ground if you’re already on Mullvad’s VPN; less interesting otherwise.
  • arkenfox user.js. Not a browser; a configuration file for stock Firefox that flips the same privacy settings LibreWolf does, plus a configurable amount more. The right answer for users who want stock Firefox’s update cadence with LibreWolf’s defaults.

Don’t use Chrome. Don’t use Edge. Don’t use Safari. The threat model these documents target includes the vendor of the browser as part of the threat.

For Devuan plus the hardening doc, the right browser stack is LibreWolf as default plus Tor Browser available for specific sessions. Add Firefox Multi-Account Containers (or arkenfox’s container patterns) to keep work, personal, and miscellaneous browsing isolated within the same browser.

This document doesn’t cover browser hardening in more detail; there are dedicated projects that do it better. PrivacyGuides.org has up-to-date recommendations and is independent of any commercial party.

Credential isolation across machines

The pattern: separate the machine that writes code (or composes emails, or signs documents) from the machine that holds the credentials to push code (send emails, distribute signed documents).

The threat model: an attacker who compromises the writing machine should not automatically gain credentials to act as you on the network. Compromise of the writing machine leaks code-in-progress, draft emails, unsigned documents, bad, but bounded. Without credential isolation, compromise of the writing machine leaks the credentials too, much worse, because the attacker can now act as you on the network and the bad version of every project gets pushed. The IronWorm npm worm in June 2026 was exactly this cascade: it stole developers’ npm publish credentials and republished itself into their own packages. The supply-chain section below covers the defences.

The Qubes pattern is the cleanest: split GPG and split SSH. A dedicated credential-holding qube has the keys; work qubes don’t. When a work qube needs to sign something or push something, it makes a qubes-rpc call to the credential qube; the credential qube performs the operation and returns the result. The credentials never leave the credential qube. The work qube can be wiped and rebuilt without losing the credentials.

The non-Qubes version, for the git-push case, is a bundle queue. The work machine creates a git bundle (no credentials needed); the bundle travels by sneakernet or by a controlled transport to a separate credential-holding machine; the credential machine pushes the bundle to the remote. The work machine never holds the remote’s credentials.

Similar patterns for other workflows:

  • Email: write on the work machine, transport drafts (encrypted) to a separate machine that holds the SMTP credentials.
  • Signing: prepare the document on the work machine; transport to an air-gapped signing machine (Tails or a Qubes offline qube); transport the signed artifact back.
  • Cryptocurrency: hot wallet for small transactions and balance display; cold wallet on an air-gapped machine for the bulk; transfer to hot wallet only what you’ll spend.

When to pick into this layer: when you’ve identified a specific credential whose compromise would unacceptably cascade. A personal GitHub credential probably doesn’t justify it. A code-signing key for production software does. An SSH key for production infrastructure does. The cryptocurrency case where balance is meaningful does.

Supply chain hygiene

The code you install is an attack surface in its own right, separate from the documents you open. Every dependency you pull through apt, Flatpak, npm, cargo, pip, or a curl-piped install script is code that runs on your machine, often the moment it installs, with your privileges and your secrets in reach. The IronWorm npm worm in June 2026 is the live example: a binary that fired on install, swept the machine for cloud, npm, AI, and wallet credentials, then republished itself through the victims’ own publishing credentials. The credential-isolation pattern above is half the defence: keep the credentials that publish or sign off the machine that installs and builds, so a compromised build environment cannot act as you. The rest, disabling install hooks where the ecosystem allows it, pinning dependencies by hash, vetting what you add, sandboxing the build of code you have not reviewed, and hardening how you publish, is in Choosing supply-chain tools.

Backup as the final answer

Backups aren’t a layer in the defensive stack; they’re the recovery answer when the defensive stack fails. The detection layer tells you that compromise happened; backups tell you how to come back.

The operational floor: Borg with daily automated snapshots to a local encrypted external drive, plus monthly rotation of a second external drive to an off-site location, plus a verified-working restore procedure. The backup principles are in Planning; the Devuan procedure that implements this floor is in Devuan secure workstation, Part 3.1.

The threat model where backups specifically matter: ransomware, disk failure, theft, fire, your own mistake (rm -rf to the wrong directory). For all of these, the answer is “restore from yesterday’s snapshot.” For the off-site-fire case specifically, the answer requires the off-site copy; backups in the same building as the original are one bad day from being no backups.

The hardest part of backups is not the technology; it’s the routine. A backup that’s never tested doesn’t restore. A backup whose passphrase is in the head of someone who’s now in the hospital doesn’t restore. Test the restore quarterly; document the recovery procedure somewhere a trusted person can find it.

Out of scope, deliberately

Things this project does not cover and where to find the equivalent treatment for each.

Browser hardening details. The cross-cutting section above gives the framework. The deep dive belongs to PrivacyGuides.org and the arkenfox project.

Operational security in general. The discipline of not leaking metadata, not reusing names, not posting on schedules, not getting photographed at the same coffee shop where you do anonymous work. This is the layer no software can provide. Read the EFF SSD, Grugq’s older essays on operational security, and the Tails operational-security documentation.

Compliance, audit logging, fleet management. This project is for individual workstations. Enterprise security at scale is a different conversation.


Choosing the right operating system: in 2026

  • For: people leaving Windows or macOS who need to choose a Linux distribution.
  • Before you start: nothing. If you don’t know what a distro is, start at Terms you’ll need.
  • Reading time: about 96 minutes end to end; the TL;DR and the Decision shortcut give the answer in five.
  • You end with: a distribution picked for your situation, and the reasoning behind it.

A guide for people who want to stop letting Microsoft and Apple own their computer. Written for beginners. Updated as things change.

TL;DR

If you’ve never used Linux and you’re coming from Windows, install Debian stable with the Cinnamon or XFCE desktop. Modern Debian installs about as easily as anything else, because since Debian 12 it ships the non-free firmware that used to make a first install painful[^debian-firmware], and it sits on the trunk rather than a corporate derivative, so it will still be the right call in five years. If you want the same thing without systemd, install Devuan instead. Linux Mint is still the most Windows-like landing if visual familiarity is the one thing you care about, but it now carries real caveats (see its entry), and this guide no longer steers first-timers to it by default.

Debian or Devuan is a destination, not a waystation; for most people it is where you can stop. If after six months you want to go further, the directions branch. For a leaner no-systemd system, Void. For bleeding edge, Arch. For total control and understanding, Gentoo. For privacy as a first principle, Qubes OS with Whonix templates, or Tails on a USB stick for sessions where nothing should persist.

Do not install Ubuntu in 2026. Its minimum RAM requirement (6GB) is now higher than Windows 11’s (4GB), its CPU requirement (2GHz dual-core) is double Windows 11’s (1GHz dual-core)[^ubuntu-req], Snap keeps silently replacing apt packages, and a local-root privilege escalation in the default Snap stack (CVE-2026-3888) was disclosed in March 2026[^cve-3888]. Ubuntu is the distro Linux was supposed to be an alternative to.

The rest of this doc shows the work. If you don’t know what a “distro” is, start at Terms you’ll need.

Why your OS matters: the biggest attack surface you have

Most people think about privacy the wrong way around. They worry about which browser they use, which VPN they pay for, whether to turn off cookies. These matter, but they’re all downstream of a larger question: what operating system is quietly watching you the whole time?

Your OS is the software that runs everything else. It sees every keystroke before your browser does. It sees every file before your password manager encrypts one. It sees every network request before your VPN touches it. If your OS is hostile, nothing you do on top of it is private. This is not theoretical: Windows sends telemetry to Microsoft by default, macOS sends analytics to Apple by default, and both platforms have quietly expanded what “telemetry” means over the last decade without really asking. Windows 11’s Recall feature takes periodic screenshots of your screen and stores them. Microsoft account login is now required for setup. Apple scans files on your own machine against a hash list and decides whether to let you open them (“notarisation”). You paid for the hardware; they took a seat at the table anyway.

Worse, data brokers have quietly industrialised what used to be the domain of intelligence services. Companies like Palantir and its peers don’t need to hack you: they buy you. Location history from weather apps, purchase history from loyalty programs, browsing patterns from the ad SDKs embedded in every free app, social graphs from anywhere you’ve logged in with Google or Facebook, call and text metadata from telco partnerships, all of it is aggregated, packaged, and resold into detailed profiles that intelligence contractors, law enforcement, and private clients purchase legally. Epstein’s little black book was not a fluke of one man’s networking. It was a demonstration of what happens when powerful people collect leverage over other powerful people, and in 2026 that same function has been industrialised and automated at scale, except now it’s your data too, not just the elite’s, and the buyers are not always people you would trust with it. Running Linux does not sever every pipe (your ISP still sees your traffic, your phone still pings towers) but it removes one of the largest and most leaky attack surfaces: the OS itself. Windows telemetry and macOS analytics are direct feeds into ecosystems where Microsoft and Apple are themselves data businesses with partner relationships you’ve never consented to in any meaningful sense. Linux running a hardened configuration with a decent firewall, combined with a trustworthy DNS resolver, means you’ve closed the hole that most people don’t even know is open. It won’t make you invisible, but it stops you from being low-hanging fruit, and in a world where data is used to build leverage, being harder to profile than the next person is not paranoia, it’s basic hygiene.

In rough priority order, the largest attack surfaces for a normal person’s online privacy are: the browser (fingerprinting, JavaScript, cookies, localStorage); DNS (every domain you resolve is visible to someone upstream unless you use encrypted DNS); your ISP (contractually knows who you are); the OS itself (telemetry, update servers, captive portal checks, NTP pings); your IP address; account linkage (logging in anywhere collapses anonymity across sessions); communication metadata (who-when-how-often is visible even on encrypted chat); hardware identifiers (MAC addresses, CPU serials); behavioural fingerprinting (typing rhythm, mouse patterns); third-party content (ad trackers, analytics, embedded fonts and pixels); email (open-tracking pixels, IP leakage); mobile devices (GPS, cell towers, app permissions, iOS and Android are both hostile by design); payment methods (cards and PayPal are a permanent financial graph); and the physical layer (WiFi probe requests, Bluetooth beacons).

The browser and DNS together account for most of a normal person’s practical privacy loss, which is why most advice starts there. So why does this project start with the OS instead of the browser? Because the browser runs on top of the OS, and a hostile OS sees everything the browser does before any browser setting takes effect. Windows 11’s Recall feature periodically screenshots your screen; an OS can log your keystrokes before the browser ever receives them. Hardening the browser on Windows or macOS is building on sand, because the substrate underneath can quietly undo it. You cannot fix the OS while you are still on Windows or macOS, because the whole system is designed around a relationship with the vendor. So you switch the OS first, which is the one move that closes the entire vendor category and gives the browser solid ground to stand on, and then you harden the browser early, as the very next thing you do. Starting with the OS is not a claim that the OS matters more than the browser; it is that the browser fix only holds once the OS underneath it is trustworthy.

A note to privacy experts and freedom fighters

This section is the distro-watching corollary of the Iron Law of Oligarchy and the entryism behind it. People who know how an operating system works have a specific obligation to people who don’t. If you are a maintainer, a security researcher, a long-time Linux user, or anyone else who can read a commit graph and recognise a capture pattern when you see one, you are the canary. The rest of us (people who don’t know how to build a kernel, who bought the machine to do our actual work) depend on you to tell us when a project has drifted, when a maintainer has been co-opted, when a corporate acquisition has changed what a distro is actually doing underneath. The pursuit of privacy is a never-ending chase because the institutional forces pulling the other way never rest. You are the ones in a position to see it first.

Specifically, if you see evidence that a widely-recommended beginner distribution has been quietly changed (telemetry added, corporate sponsorship introducing policy changes, governance captured, package trust compromised) post about it loudly, write it up, make it findable. Cautionary cases already exist. Ubuntu was the friendly, accessible Linux for a decade; the Snap enforcement and the 2026 system-requirements jump are the current symptoms of a long drift. Linux Mint is the recommendation most experienced users still give to Windows migrants today, and its very popularity makes it the most obvious next target for capture or compromise. The project is currently clean (independent wireshark audits in 2025 found no Mint telemetry beyond basic update-server contact[^mint-telemetry]) but there is already an unexplained Cinnamon memory leak on Mint 22.2 that was reported to the official repository on 22 December 2025, four months ago, and remains open and unassigned as of this writing (GitHub issue linuxmint/cinnamon#13298[^mint-leak]). One open bug does not prove capture, but it does prove the project’s quality-control is slipping at exactly the moment its install base is growing fastest on the back of the Windows 10 end-of-life migration. Users who upgraded to Mint 22.2 and noticed their machine getting slower over the winter were not imagining it.

If you’re someone who can tell when the pattern is getting worse, please tell us.

The practical takeaway for your own setup: treat every recommendation in this document, including the strong ones, as true right now, not forever. Check on your distro once a year. Read the project’s commit history and governance disputes, not just its marketing page. When a project starts behaving differently, leave.

Why Windows and Mac stopped being good

Windows 10 reached end-of-support on 14 October 2025[^w10-eol]. Microsoft’s answer is Windows 11, which requires TPM 2.0, Secure Boot, and a CPU from an approved list; hundreds of millions of otherwise-working machines don’t qualify. For the ones that do, the default install now includes: Recall taking periodic screenshots and storing them, a forced Microsoft account at first boot, Copilot wedged into the shell, advertisements in the Start menu and on the lock screen, ads in the file manager, Cortana, and a steady background push toward OneDrive and Microsoft 365 subscription services. Telemetry is on by default and deliberately hard to fully disable, even when you think you’ve switched everything off in Settings, you haven’t fully. Updates install when Microsoft decides, not when you do, and they have bricked machines more than once. You need a Microsoft account just to finish setup. The OS phones home constantly. You are running a surveillance platform that also plays games.

Windows defenders will say “just tweak the settings.” The fact that you have to spend hours un-defaulting your way out of corporate surveillance to use your own machine is the indictment, not the defence.

Mac is cleaner and more coherent, and Apple locks you in tighter than anyone. The hardware is proprietary. The software ecosystem is a walled garden. Repairs require Apple’s permission, financially if not literally. Gatekeeper actively blocks software Apple hasn’t blessed (notarisation puts Apple in the middle of software distribution). Every few years Apple drops support for hardware that is still perfectly capable, you paid $3,000 for a machine that Apple will tell you, three to five years later, can no longer run the current OS. Applications get sandboxed more aggressively every release. The M-series chips are fast and efficient; you are also not allowed to know what is running on them at a deep level. The privacy posture Apple markets hard is real only up to the point where Apple’s own telemetry starts, and Apple scans files on your own machine against a hash list to decide whether to let you open them. You’re not buying a computer. You’re renting an expensive aesthetic.

Both companies have decided that your computer is a client for their cloud. Linux is the last mainstream way to run your own computer. It isn’t perfect, but it is yours, you choose the kernel, the init, the shell, the filesystem, the package manager, the desktop, and which parts of it phone home (in most distros, none). In 2026 you can also run Linux on the same hardware most people buy, watch Netflix in a browser, join a Teams call, edit photos, write code, and stream to a TV, all without a subscription or a vendor account. For most people, the remaining friction is smaller than the friction of putting up with Windows 11.

When keeping a Mac for one specific thing is OK

A realistic concession the doc owes you: for some specialised production work, Mac’s software stack is genuinely hard to match on Linux, and the honest posture is not “leave Mac, period” but “leave Mac for general computing, and keep a Mac as a dedicated tool if you actually need what it does.” Final Cut Pro and Logic Pro have no Linux equivalent. DaVinci Resolve runs on Linux but the free version strips out AAC and MP3 in/out, several codecs need manual workarounds, and driver integration is rougher than on Mac. Adobe refuses to ship for Linux and the open-source alternatives (GIMP, Krita, Inkscape, Kdenlive) are good but not drop-in replacements for a working Creative Cloud pipeline. Logic, Pro Tools, and most of the commercial audio-production ecosystem assumes Mac or Windows.

Bryan Lunduke, who is otherwise one of the louder voices against consumer-OS drift and runs OpenMandriva as his daily driver, still uses a Mac for video production on The Lunduke Journal. That isn’t a contradiction. It’s a working adult’s acknowledgment that the right tool for one specific professional workflow is not always the right tool for the rest of computing. A sovereignty-minded posture does not require that every machine in your house run Linux. It requires that your general-purpose computing (the machine you browse on, the machine you write on, the machine that holds your files) is not a surveillance client. A Mac Mini or MacBook kept offline-ish as a dedicated video workstation is a tool, not a surrender. The distinction that matters is whether a given machine is your general computing life or a specialised appliance.

Terms you’ll need

A handful of words get used constantly in the Linux world and almost never defined. If you’re new, this section is here so the rest of the doc reads normally.

Kernel. The single piece of software that talks directly to your hardware, CPU, RAM, disk, keyboard, screen, network. Every other program asks the kernel instead of touching hardware itself. Linux is, strictly, just a kernel. Linus Torvalds wrote it in 1991 and still runs its development. When people say “Linux,” they usually mean a kernel plus a bundle of other software built around it.

Distribution, or distro. A specific bundle: the Linux kernel plus a package manager, a set of default applications, a desktop environment, and a bunch of defaults about how things are configured. Ubuntu is a distro. Debian is a distro. Fedora is a distro. There are hundreds. They mostly differ in which bundle they ship and how it’s maintained.

Base distro, upstream, downstream. Some distros are built from scratch (Debian, Arch, Gentoo, Slackware, Void, Alpine). Others are built on top of those. Ubuntu is built on Debian. Linux Mint is built on Ubuntu. When something is fixed, it often gets fixed at the source first and flows down: Debian fixes a bug, Ubuntu pulls the fix from Debian, Mint pulls it from Ubuntu. The distro your distro is built on is called its upstream. A distro built on top of yours is downstream. This matters mostly for two things: when something breaks, the fix usually originates upstream; and when you search for help, tutorials for your upstream mostly apply to you with small adjustments.

Package manager. The tool that installs, removes, and updates software on your system. apt on Debian and Ubuntu and Mint, dnf on Fedora, pacman on Arch, zypper on openSUSE, xbps on Void, apk on Alpine. They are all solving the same problem (don’t download installers from random websites; let a vetted repository handle it) with different commands.

Init system. The first program the kernel starts after boot. It then starts everything else, networking, logging, your graphical login screen. For two decades the default was a simple script-based system called sysvinit or its descendants (OpenRC, runit). In 2010 a Red Hat engineer named Lennart Poettering started a replacement called systemd. Systemd does much more than just start processes; this is the core of the controversy. See The systemd problem below.

Desktop environment. The visible part of Linux: the taskbar, window decorations, file manager, default theme. GNOME, KDE Plasma, Cinnamon, XFCE, MATE are the main ones. A single distro usually offers several; the “flavour” or “edition” you download picks one. See Picking a desktop below for what each one actually means in practice.

Rolling release vs stable release. A rolling distro updates individual packages continuously: Arch, Void, Tumbleweed, Gentoo, Devuan’s testing branch. You get new software fast but occasionally something breaks. A stable-release distro freezes the whole package set for two or three years, only shipping security fixes: Debian stable, Ubuntu LTS, Mint, Rocky Linux. You get older software but almost no surprises.

LTS. “Long-Term Support.” A specific release is promised updates for five or ten years. Ubuntu LTS releases (every two years, supported five years without Ubuntu Pro, ten years with it) are the famous example.

systemd-free. Shorthand for distros that still use sysvinit or OpenRC or runit instead of systemd. Devuan, Artix, Void, Gentoo (by default), Alpine, Vendefoul Wolf. See The systemd problem.

Live USB. You can put Linux on a USB stick and boot your computer from it without installing anything. The distro runs from the USB, touches nothing on your hard drive. Good for trying a distro, or for privacy tools like Tails where the whole point is leaving no trace. If you can’t even make a USB stick (locked-down work laptop, no admin, no spare hardware) Fabrice Bellard’s JSLinux (bellard.org/jslinux) runs a Linux shell (Alpine, Buildroot, or Fedora RISC-V) directly in a browser tab via WebAssembly. It is a curiosity, not a distro evaluation: the hardware is fake, so it tells you nothing about whether your real Wi-Fi or trackpad will work, and the demo images are tiny. Good for thirty seconds of “so this is what a shell looks like.”

Flatpak and Snap. Two different ways to install an application without worrying about system libraries, the app ships with everything it needs. Flatpak is community-run, cross-distro, and generally fine. Snap is Canonical’s (Ubuntu’s parent company) version; its backend is proprietary and its behaviour has been controversial. This is why Linux Mint strips out Snap on install.

AUR (Arch User Repository). A community-run collection of build scripts for Arch Linux that makes almost any software installable via a simple command, even if it isn’t in the official Arch repos. Roughly 100,000 packages. Only applies to Arch and its derivatives (EndeavourOS, CachyOS, Artix).

Immutable base filesystem. A newer approach, used by Fedora Silverblue, Bazzite, and a few others. The core operating-system files are marked read-only and updated as a single transaction, either the whole update succeeds and you reboot into the new system, or it fails and you stay on the old one. You install your applications in a separate layer (usually Flatpak, or a container tool like Distrobox). The practical consequence: you can’t easily apt install a system package, but you also can’t break your system by accident.

Hardening. In OS security, “hardening” means changing the defaults so that if something goes wrong, the damage is smaller. Vanilla Linux boots with a lot of features enabled that most people never use (some kernel modules, some network protocols, some permission combinations) and any of those can become an attack surface if a bug turns up. A hardened system switches those off by default and tightens the remaining ones. See the Kicksecure entry for a concrete example.

Picking a desktop: what Cinnamon, GNOME, XFCE, KDE actually mean

A desktop environment (DE) is the visible part of Linux, the taskbar, the window decorations, the file manager, the default look. The DE is separate from the distribution; most big distros offer several, and you pick the one you want at install time by choosing an “edition” or “flavour” (e.g. “Fedora Workstation” ships GNOME, “Fedora KDE Spin” ships KDE Plasma, same underlying Fedora). Here’s what the main options actually are.

Cinnamon. Looks like Windows. Taskbar at the bottom, start-menu-style application launcher in the corner, system tray, minimise/maximise/close in the top-right of each window. Moderately heavy, uses more RAM than XFCE but less than GNOME. Developed by the Linux Mint project, which is why it’s Mint’s flagship edition. Runs well on anything with 4GB of RAM and up. Pick this if you’re leaving Windows and want the transition to be invisible. First-class on Linux Mint; available as a package on Debian, Fedora, Arch, and most others.

GNOME. Looks like macOS, or like a tablet-first desktop. No taskbar by default; an “Activities” overview for switching apps; a single top bar. Heavy on RAM (roughly 1.5–2.5 GB idle). Minimalist to a fault, many options Windows/KDE users expect require installing extensions. Opinionated in ways that frustrate some users and delight others. Ships as the default on Fedora Workstation, Ubuntu, Pop!_OS, Zorin OS, and Debian’s default edition. Pick this if you liked the Mac, or you want a clean, gesture-first desktop and don’t mind trading configurability for coherence. Note: GNOME’s project leadership has been politically active in ways some readers will care about; see GNOME’s political turn and the Code of Conduct asymmetry for the record.

KDE Plasma. Maximally configurable. Every visible element (panel, menus, widgets, animations, keyboard shortcuts, notification behaviour) can be moved, swapped, or reconfigured. Surprisingly lean in recent versions, Plasma 6 idle RAM is competitive with Cinnamon. Ships as the default on openSUSE, Fedora KDE Spin, KDE Neon, Kubuntu, and the KDE edition of Linux Mint. Pick this if you want control over every pixel, or if you’re the kind of person who likes tweaking the desktop to match your exact preferences.

XFCE. Minimal, lightweight, traditional. Looks approximately like a classic Windows desktop out of the box, taskbar, menu, basic panel. Uses very little RAM (roughly 400–700 MB idle) and runs well on modest hardware. Stays out of your way. Ships as the default on Xubuntu, MX Linux, and the XFCE editions of Linux Mint, Debian, and Manjaro. Pick this for a ten-year-old laptop, or for a daily driver where you want nothing fancy.

MATE. A fork of the old GNOME 2 (pre-2011 GNOME, before the GNOME 3 redesign that alienated much of the existing user base). Traditional, stable, modest RAM use. Similar role to XFCE but with a different aesthetic lineage, slightly more polished, slightly less minimal. Ships on Ubuntu MATE and the MATE edition of Linux Mint. Pick this if you specifically liked GNOME 2 or you want a traditional desktop with a bit more finish than XFCE.

Two smaller DEs worth knowing about if your hardware is genuinely old: LXQt (lightweight Qt-based desktop, even leaner than XFCE at ~300 MB idle; default on Lubuntu) and Budgie (a Solus-project desktop, modern-looking, moderate weight; ships on Ubuntu Budgie and Solus). Neither is a primary recommendation here but you may see them mentioned.

One thing worth naming about XFCE in particular: if you install the XFCE edition of Mint, Debian, Devuan, Artix, Ubuntu, and Void and line them up, they look almost the same out of the box. That’s not a coincidence and it’s not laziness. XFCE’s upstream ships a coherent set of tools (Thunar file manager, xfce4-terminal, Mousepad text editor, Ristretto image viewer, Xarchiver archive manager, xfce4-taskmanager, xfce4-screenshooter, xfce4-appfinder, xfce4-panel, xfwm4 window manager, xfdesktop background) that distros rarely deviate from. The init system underneath (systemd, OpenRC, runit, sysvinit) doesn’t change which XFCE apps are installed. Init is the plumbing; the desktop is the visible furniture. Practical consequence: if you learn XFCE on Mint you know XFCE on Devuan and Void; moving between them is a matter of learning the package manager and the init system, not a new desktop.

Practical summary: Cinnamon if you’re coming from Windows. GNOME if you’re coming from Mac or you want modern-and-minimal. KDE Plasma if you’re a tinkerer who wants control. XFCE if your hardware is modest or you’re conservative. MATE if you specifically miss GNOME 2.

Decision shortcut

If you’re coming from Windows and want it to feel similar:

  1. Primary: Debian stable with the Cinnamon or XFCE desktop. Installs cleanly on modern hardware (firmware included since Debian 12), sits on the trunk rather than a corporate derivative, and is the choice you will not have to revisit. Cinnamon gives you most of Mint’s Windows-like layout on the trunk. Pick this unless raw visual familiarity is the only thing you care about.
  2. Linux Mint Cinnamon. The closest cosmetic match to Windows, rock-solid feel, huge community, strips out Snap. Pick this if visual familiarity overrides everything else, and read the caveats in the Linux Mint section first (a shipped Cinnamon memory-leak regression, and capture risk that grows with its popularity).
  3. Zorin OS. Windows-lookalike polish, explicit migration tooling, GNOME under the hood. Pick this if you want even more hand-holding and are fine paying for Zorin Pro for extra layouts.

If you’re coming from Mac and want the craft and cohesion:

  1. Primary: Fedora Workstation with GNOME. The closest Linux comes to the Mac “everything designed together” feel. Pick this if you valued the Mac’s coherence and you’re OK with annual major-version upgrades.
  2. openSUSE Tumbleweed with KDE Plasma. Pick this if you want a rolling release with serious testing and you prefer KDE’s configurability to GNOME’s opinionation.
  3. Debian with GNOME. Pick this if you want Fedora’s feel without the Red Hat / IBM corporate backing.

If you want something stable, quiet, and long-lived (my pick for most people once they’re off training wheels):

  1. Primary: Debian stable with XFCE. The default for people who want to install once and stop thinking. Non-corporate governance, massive repo, boring on purpose.
  2. Devuan stable. Pick this if you want Debian without systemd. See The systemd problem.
  3. MX Linux. Pick this if you want Debian stable with nicer defaults and a friendlier installer.

If you want no systemd on principle or practice:

  1. Primary: Devuan. Debian-compatible, largest non-systemd ecosystem, supports sysvinit / OpenRC / runit / s6. Pick this if you want apt and Debian stability without the systemd dependency tree.
  2. Void Linux. Independent from the ground up, runit, xbps, rolling. Pick this if you want something leaner and more modern than Devuan.
  3. Artix. Arch minus systemd. Pick this if you want AUR access and rolling-release freshness with init freedom.
  4. Gentoo. Pick this if you want source-based compilation and full control over every build flag.

If you want bleeding edge:

  1. Primary: Arch Linux. Best wiki in all of Linux, AUR has almost everything, rolling. Pick this if you’re comfortable reading documentation and want the canonical Arch experience.
  2. EndeavourOS. Pick this if you want Arch with a friendly installer and nothing else added.
  3. CachyOS. Pick this if you want Arch tuned for performance on modern CPUs.
  4. openSUSE Tumbleweed. Pick this if you want rolling release with real QA behind it.

If privacy is your first principle:

  1. Primary: Qubes OS with Whonix templates. Virtualisation-based isolation; every app in its own VM; Tor-forced qubes available. Pick this if you have the hardware (16GB+ RAM, compatible CPU) and you’re willing to learn the model. 1.1. Primary: Qubes with default Fedora and Debian templates only, then add Whonix templates once you’re comfortable. 1.2. Qubes with Whonix-Gateway and Whonix-Workstation as the first templates. Pick this if Tor isolation is the reason you’re installing Qubes.
  2. Tails. Pick this if you want an amnesic live-USB that routes everything through Tor and forgets when you shut down.
  3. Whonix on your existing host. Pick this if you want Tor-isolated VMs without the Qubes commitment.
  4. Kicksecure. Pick this if you want Whonix’s hardening without Tor in the mandatory path.

If you want gaming to just work:

  1. Primary: Bazzite. Fedora-based with an immutable base filesystem, preconfigured for Steam/Proton, desktop and handheld variants, HDR/VRR supported.
  2. CachyOS. Pick this if you want bleeding-edge kernels and Arch-style maintenance.
  3. Nobara. Pick this if you want a Fedora-based approach with a more traditional (mutable) filesystem.
  4. OpenMandriva (as a general daily driver that also games). Pick this if you want a community-governed RPM-family distro with KDE Plasma, Proton pre-packaged in the repos, and no corporate owner. Not specialised for gaming the way 1–3 are, but Bryan Lunduke runs it as his primary machine and games on it; if that posture fits your priorities better than frame-rate optimisation, this is the pick.

If you want BSD:

  1. Primary: OpenBSD. Smallest audited codebase of any general-purpose OS, security-correct by default. Pick this if minimalism-as-security is the goal.
  2. FreeBSD. Pick this if you want a production-serious Unix for servers or a NAS.
  3. GhostBSD. FreeBSD’s base with a MATE desktop, graphical installer, and ZFS-by-default. Pick this if you want FreeBSD for desktop use without the bring-your-own-DE assembly FreeBSD vanilla expects.
  4. NetBSD. Pick this if you have unusual hardware or want extreme portability as a principle.

My pick for most readers leaving Windows: Debian stable with the Cinnamon or XFCE desktop on the primary machine, Tails on a USB for anything sensitive. Mint is the fallback only if Windows visual familiarity is the one thing you cannot compromise on. My pick if this doc had to compress into one sentence: Void Linux, independent, lean, no systemd, not owned by a corporation, built by people who ship software instead of running a political project.

What not to pick in 2026

  1. Ubuntu. The reason most of this doc exists. Ubuntu 26.04 LTS (released 23 April 2026, same day as this doc) raised the minimum RAM requirement to 6GB, up from 4GB; Windows 11 still lists 4GB minimum. Ubuntu now requires 50% more RAM and double the CPU speed of the proprietary OS it was supposed to be a lightweight alternative to[^ubuntu-req]. Snap installs silently replace apt install commands; the Snap backend is proprietary; and in March 2026 Qualys disclosed a local-root privilege escalation (CVE-2026-3888, CVSS 7.8) in the interaction between Snap and systemd-tmpfiles that affects default Ubuntu Desktop 24.04 and later installations[^cve-3888]. An “AI bubble” has driven RAM prices sharply higher at exactly the moment Canonical decided to require more of it. The 2026 RAM bump affects every Ubuntu user at a moment when the project’s answer to “why” is essentially “because modern GNOME needs it.” This is not the Ubuntu that existed in 2015. Do not install new machines on it. Mint and Debian can both do everything Ubuntu does for you.
  2. Manjaro. Arch-derived but ships its own delayed repo that’s regularly the cause of breakage, and the team has shipped expired SSL certificates on their own website more than once. If you want Arch, use Arch or EndeavourOS. Manjaro is strictly worse.
  3. elementary OS. Beautiful but the project has been slow since the 2022 co-founder feud. Fine if you’re already on it; not a fresh recommendation.
  4. Deepin. Chinese-state-adjacent, ships its own desktop with closed components, repeated privacy concerns around telemetry. Avoid.
  5. CentOS Stream as a server base. Red Hat turned CentOS into upstream-of-RHEL rather than downstream. Rocky Linux and AlmaLinux are the real CentOS successors; Debian is the no-corporate-middleman alternative.
  6. Pop!_OS while waiting for COSMIC. COSMIC is still alpha/beta as of early 2026. If you’re starting fresh, start on Fedora or Mint and switch later if COSMIC ships well.
  7. Anything marketed as an “AI Linux” or “crypto-native OS.” These are usually re-themed Ubuntu with a credential-stealing angle.

How this doc is structured

This is a living reference, not a ranking. The Linux landscape has a stable core (Debian, Fedora, Arch, Slackware, Gentoo) and a churning frontier (immutable distros, Nix-style systems, gaming-focused Fedora remixes). The advice here separates the two so the stable parts age slowly and the volatile parts can be updated without rewriting the doc. Each per-distro entry covers: what it is, who owns it, who it’s for, what it’s bad at, current trajectory, and roughly where it sits on the community politics axis.

What counts as “widely used” in 2026

Desktop Linux crossed roughly 4.7% global share in 2025 and is tracking toward 5-6% by end of 2026, driven substantially by Windows 10’s October 2025 end-of-support[^linux-share]. The United States crossed 5% in June 2025. India sits at roughly 16% as of mid-2024, the highest major-economy share. Steam’s Hardware Survey has Linux at around 2.3-3% through 2025, with SteamOS (Arch-based) the single largest Linux slice.

Distribution-level numbers are fuzzier because most tracking is pageviews or downloads, but roughly: Ubuntu remains the largest desktop Linux by install count, though its enthusiast mindshare has cratered since the Snap controversy. Linux Mint is consistently in DistroWatch’s top few and dominates the Windows-refugee segment. Fedora has grown as developers leave Ubuntu. Debian’s raw footprint is enormous in hidden form (it is the base of Ubuntu, Mint, Kali, dozens more) but its direct desktop count is smaller. Arch has 17–25% mindshare among technical users depending on how you count derivatives. Gentoo, Void, NixOS, and openSUSE Tumbleweed are each smaller but steady niches with strong retention.

A few specific data points worth knowing (treat all of these as directional, they come from different measurement methods with different biases):

  • Stack Overflow’s 2024 Developer Survey (65,000+ respondents) has Ubuntu at roughly 27.7% of developer personal use and 27.7% professional use, with Debian around 9.8% personal and 9.1% professional. Other Linux distributions (Arch, Fedora, NixOS, Pop!_OS, etc.) together account for 17.6% personal / 16.7% professional.
  • DistroWatch’s page-view tracker (which measures community interest, not actual installs) regularly has Linux Mint in the lead at roughly 2,400 daily hits, followed by MX Linux (~2,280), EndeavourOS (~1,640), and Manjaro (~1,400+). Note this under-represents enterprise distributions; RHEL, used by a majority of Fortune 500 companies, ranks in the 50s by page views.
  • Enlyft’s corporate-adoption data puts Ubuntu at roughly 29% of Linux distribution market share across their tracked deployments.
  • Canonical’s revenue grew from $175M in 2021 to $251M in 2023, a 43% increase, largely Ubuntu Pro and cloud licensing rather than desktop.
  • Red Hat generates roughly $1.87 billion in quarterly revenue after the IBM acquisition, with about 67% share of the paid enterprise server market.
  • Arch and its derivatives (EndeavourOS, CachyOS, Manjaro, Garuda, SteamOS) together capture roughly 47% of DistroWatch poll respondents when counted as a family. Among Linux kernel developers specifically, Fedora is the most common distribution at roughly 45%, followed by Arch at roughly 30%.

“Widely used” in this doc = anything on Debian / Ubuntu / Mint / Fedora / Arch / Manjaro-EndeavourOS / openSUSE / Gentoo / Void / NixOS / Devuan / Artix / Pop!_OS / Zorin / Bazzite / Alpine / Qubes / Tails / Whonix, plus the BSDs and a few niche principled entries.

One note on specialised distributions: Kali Linux, Parrot OS, BlackArch, REMnux, and similar distros exist but are not in this guide. They are penetration-testing or security-research tools, live-USB or VM images built to ship with hundreds of offensive-security utilities and with their hardening and defaults tuned for that work, not for daily use. If someone recommends Kali as your first Linux to switch to, that recommendation is wrong; use Mint or Debian. If you later become a security professional and need those tools, you already know where to find them.

The install-once-and-forget axis

One axis most distro comparisons miss: how much attention a system needs from you per month once it’s set up. This is orthogonal to stability and to package freshness.

Low-attention: Debian stable, Linux Mint, AlmaLinux, Rocky, Devuan, openSUSE Leap, FreeBSD. You install them and they update cleanly for years; the only reboots are kernel security updates. Debian stable is the canonical example, a Debian 12 install from 2023 will run through 2028 without breaking user-space.

Medium-attention: Fedora Workstation, openSUSE Tumbleweed, EndeavourOS, Pop!_OS, NixOS. You’ll touch them weekly or monthly. Fedora major upgrades every six months, Tumbleweed’s rolling updates, NixOS channel bumps. Nothing breaks often, but you’re in the loop.

High-attention: Arch, Manjaro, Void, Gentoo, Artix, Kali. The social contract is “read the news before you update.” Gentoo adds the compile-time axis. These reward attention and punish neglect; if you disappear for six months and run an update, you will spend an afternoon unwedging things.

The axis matters because the right answer depends on the machine. Daily-driver laptop for work → low-attention. Tinkering machine or homelab → medium or high. Don’t put a rolling release on your grandmother’s computer.

The systemd problem

For the first two decades of Linux, the program that started everything else at boot was a small thing called sysvinit: it read shell scripts, it launched daemons, it got out of the way. In 2010, a Red Hat engineer named Lennart Poettering released a replacement called systemd. Technically systemd starts faster and manages service dependencies better than sysvinit did. Culturally, systemd has become one of the most divisive projects in open-source history, for reasons that are now much clearer than they were in 2010.

The first objection was philosophical. Unix has a founding principle: each program should do one thing and do it well, and programs should be composed together with simple interfaces. Systemd does not follow this. It started as an init system and has grown to absorb logging (journald), network management (networkd), device management (udevd), user management (homed), DNS resolution (resolved), time synchronisation (timesyncd), container management, and now user database schemas. If a system component touches systemd, it tends to become hard to replace, because systemd provides and consumes its own interfaces. This is the opposite of what Unix was.

The second objection is governance. Red Hat (now IBM) employs many of systemd’s core maintainers, and systemd became the default init on nearly every major Linux distribution (Debian, Ubuntu, Fedora, openSUSE, Arch) by a mix of technical argument and ecosystem momentum, over the objections of significant portions of those projects’ communities. Debian had a public, bitter vote about adopting systemd in 2014; the people who lost that vote left to create Devuan. The grievance wasn’t only technical. Systemd was one of the first major examples of a corporate-employed maintainer successfully imposing a new piece of critical infrastructure on the broader Linux ecosystem over community objections. Once in, it was hard to remove.

The third objection is recent and concrete. In March 2026, systemd merged pull request #40954, adding a birthDate field to its user-record schema[^systemd-pr]. The stated reason was compliance with new age-verification laws in California (AB-1043), Colorado (SB26-051), and Brazil (Lei 15.211/2025). The PR was submitted by a first-time contributor and received 37 negative reactions to 1 positive before a Microsoft employee merged it. When the community filed a revert PR the next day (#41179), Lennart Poettering closed and locked it without merging, saying the field was optional and enforced no policy[^systemd-revert]. The community’s objection was not the technical merit of an optional JSON field; it was the governance pattern. A critical Linux infrastructure component had its user-identity schema changed to accommodate state age-verification legislation, the change was approved by a corporate-employed maintainer against overwhelming community opposition, and dissent was closed and locked by the project’s founder. This is exactly what the Iron Law of Oligarchy looks like in software.

The corporate affiliations that made this possible are worth naming directly, because they’re all public record. Kinvolk GmbH, the German systemd-adjacent company, was acquired by Microsoft in April 2021. Lennart Poettering moved from Red Hat to Microsoft around July 2022. Christian Brauner, another core systemd maintainer, moved from Canonical to Microsoft around the same period. Amutable GmbH was incorporated in August 2025 by former Kinvolk / Microsoft personnel and announced publicly in January 2026, seven months after incorporation and roughly two months before the birthDate merge. The Sovereign Tech Agency (a German-government program that funds open-source infrastructure) has invested approximately EUR 855,000 into systemd across two funding rounds; the contractor-of-record is not publicly disclosed on the agency’s funding page, which is a matter of the agency’s choice rather than a conspiracy, but it does mean the money trail can’t be independently traced. All of the incorporation filings are retrievable from the German Handelsregister. None of this proves capture on its own. All of it is consistent with the pattern the Iron Law describes: systemd’s commercial gravity now sits with a Microsoft-adjacent cluster of personnel, and one of Linux’s most critical pieces of infrastructure has a corporate centre that is no longer Red Hat.

The fourth objection came two days later. On 17 March 2026, Qualys disclosed CVE-2026-3888, a local privilege escalation in Ubuntu Desktop 24.04 and later[^cve-3888]. The bug arises from the interaction between Canonical’s snap-confine and systemd-tmpfiles, neither broken alone, but the combination lets an unprivileged local user gain root by manipulating the timing of systemd’s scheduled temp-file cleanup. CVSS 7.8, high severity. The exploit window is a 10-to-30-day delay, which is not safety; it’s a timer. This is the kind of failure mode that the “do one thing well” principle exists to prevent: when systemd is responsible for cleaning up temp files, and also indirectly setting up snap sandboxes, and also managing service schedules, the interactions between these responsibilities become the attack surface.

What happened after the investigation was published is the part that’s hardest to dismiss as coincidence. TBOTE (the project that compiled the corporate-affiliations record above) documented coordinated automated reconnaissance against its own site in the days after publication: roughly 1,285 requests from 70 IPs traced to Meta infrastructure, 1,659 requests from 18 IPs traced to Microsoft’s OpenAI crawlers, over 5,500 requests from 1,100+ IPs in a 72-hour window traced to Google Cloud scanner clusters, and probing by Palo Alto Networks’ Cortex Xpanse, an enterprise attack-surface-management product whose licensing runs into six figures per year. None of this is dispositive on its own; corporate infrastructure scrapes a lot of small websites, and a single project’s logs are the project’s own characterisation rather than independently verified evidence. But the pattern of who specifically showed up to map a site that named them, with tools that cost real money, is the kind of empirical correlate the Iron Law predicts. Read TBOTE’s logs directly at tboteproject.com if you want to form your own view.

The practical takeaway: systemd is not going away, and on most distros you will use it. But the case for choosing a systemd-free distro (Devuan, Artix, Void, Gentoo with OpenRC, Alpine, Vendefoul Wolf) got materially stronger in March 2026. If you were on the fence, the birthDate merge and CVE-2026-3888 landing in the same week are good reasons to step off.

Community politics and the vibe test

The distros themselves don’t have politics. The communities, founders, and corporate backers do, and that shapes what features get prioritised, what governance disputes erupt, and how a project behaves under pressure. Roughly sorted right to left:

Right-coded: Gentoo, Void, Devuan, Artix, Alpine, Slackware, Vendefoul Wolf, OpenMandriva, Omarchy, GhostBSD, OpenBSD. These communities cluster around sovereignty, minimalism, anti-dependency posture, scepticism of corporate and political gravity. Gentoo attracts the purists; Void and Devuan are explicitly reactive against systemd (Red Hat’s piece of OS centralisation); Alpine’s ethos is small-and-audited; Vendefoul Wolf ships with explicit anti-AI, anti-telemetry, anti-Wayland defaults. OpenMandriva is a French 1901 non-profit that has stayed structurally outside corporate orbit and is the most visible non-Mageia continuation of the Mandrake lineage; Omarchy is DHH’s opinionated Arch + Hyprland distribution and sits here by the cultural alignment of its maintainer. GhostBSD recently replaced X.Org with XLibre (the fork that split off from X.Org over governance) and is quietly one of the clearest “engineering over ideology” moves any desktop OS made in 2026. OpenBSD sits here by ethos more than politics, Theo de Raadt’s project is as far from Silicon Valley progressivism as any mainstream OS gets. These communities will leave you alone if you don’t bring politics into their issue trackers.

Center: Debian, Linux Mint, MX Linux, Arch Linux, EndeavourOS, openSUSE, Slackware-adjacent projects, FreeBSD. Technocratic-neutral. Debian has a formal democratic constitution and has resisted capture by any single direction; its one major recent political fight was the systemd vote in 2014. Mint is pragmatic and anti-Canonical-bloat. Arch ships software and documents it. openSUSE and FreeBSD have corporate backers but the communities are engineering-first.

Center-left: Ubuntu / Canonical, Pop!_OS / System76, Manjaro, Zorin. Silicon-Valley-adjacent defaults. Canonical is a British-headquartered corporate entity with standard corporate apparatus; the Snap controversy is a corporate enclosure move more than a political one, but the overall posture is progressive-corporate. System76 is a Denver hardware company whose politics track standard US tech-left. Zorin is a commercial shop with mainstream progressive branding.

Left-coded: Fedora / Red Hat / IBM, GNOME, NixOS, Tails, Trisquel, PureOS, Qubes, Whonix. Fedora is directly a Red Hat property, and Red Hat under IBM runs one of the most visible corporate DEI programs in open source. GNOME has been involved in some of the loudest code-of-conduct fights in desktop Linux over the last decade. NixOS had a major governance crisis in 2024 around military-adjacent contracts (Anduril) and CoC enforcement; the fallout produced the Lix fork and reshaped the NixOS Foundation. Tails is explicitly aligned with activist-left communities, their own documentation links to a tutorial called “Tails for Anarchists.” Trisquel and PureOS live inside the FSF’s political orbit. Qubes and Whonix are academic-privacy-left in community composition though generally lower-drama in operation.

If you want software whose community will leave you alone: Void, Devuan, Gentoo, Alpine, Debian, OpenBSD. If you want corporate-grade polish and don’t mind the cultural package: Fedora, Ubuntu, Pop!_OS. Everyone else is mostly just shipping an OS.

How the camps see each other

A lighter companion to the previous section. If you want to understand the culture of each camp, listening to what they say about each other is clarifying.

What Arch users say about Debian: “Your packages are ancient. You’re running software from two years ago and calling it stable.” And about the bureaucracy, it takes a formal governance process for a package to enter Debian proper, which Arch users see as sclerotic.

What Fedora users say about Debian: “At least we’re somewhat current. Debian stable is a museum.” And on release cadence: “‘Whenever it’s ready’ means never.” Plus a sharper point about technical leadership: Fedora ships new desktop infrastructure first (PipeWire, Wayland, Btrfs by default) while Debian inherits it years later.

What Debian users say back: with complete calm, “My server has been running for four years without a reinstall. Can you say the same?” They point out that Debian is the mother of a hundred distros, that Ubuntu exists because of Debian, that apt is the most copied package-management model in existence. They also note that Arch users spend more time fixing their system than using it, and Fedora users are essentially unpaid Red Hat beta testers. The Debian user’s energy: “We were here before you, we’ll be here after you, and we don’t care what you think.”

What Arch users say to Devuan users: respectful, “Good call ditching systemd, but why run a Debian fork when you could run something that doesn’t hold your hand at all? Come to Artix.” Weird mutual appreciation between the two contrarian camps.

What Fedora users say to Devuan users: dismissive, “Why are you clinging to sysvinit like it’s 2004? Systemd exists for a reason, just learn it.” Fedora is Red Hat’s proving ground, and systemd is a Red Hat project, so Fedora users tend to be true believers.

What Gentoo users say to Arch users: condescending, “You use Arch because you couldn’t setup Gentoo.” Gentoo’s identity is built around source compilation and USE-flag-level control; from that vantage, Arch’s binary-package convenience reads as a shortcut. The meme below is the canonical version of this dynamic.

None of this is meant to decide anything for you. It’s a sanity check: whatever distro you pick, here is approximately what the other camps will think about your choice.

GNOME’s political turn and the Code of Conduct asymmetry

GNOME has the highest “loudest CoC fights per capita” rate in desktop Linux, and the pattern that’s emerged across 2024–2026 is specific enough to name. Prominent GNOME maintainers, using either the project’s own blog network at blogs.gnome.org or personal accounts that publicly identify them as GNOME contributors, have repeatedly characterised people and companies they politically disagree with as “Nazis,” “fascists,” or “racists.” The GNOME Code of Conduct[^gnome-coc] explicitly forbids personal attacks, demeaning language, and “any other conduct which could reasonably be considered inappropriate in a professional setting.” No publicly disclosed CoC enforcement action has followed any of the incidents below.

The documented record:

  • April 2025: Tobias Bernard, a GNOME design contributor, published “The Elephant in the Room” on blogs.gnome.org over the suspension of board member Sonny Piers. The post repeatedly closes with the line “Fuck Nazis, GNOME is Antifa.” A subsequent post from Allan Day (also blogs.gnome.org, also defending the GNOME Foundation’s handling of the case) treats this framing as unremarkable rather than flagging it.[^gnome-bernard][^gnome-day]
  • July 2025: Jordan Petridis (GNOME release team member, runtime maintainer, GNOME Nightly maintainer, Newcomers experience lead, Podcasts maintainer per his own GNOME wiki page[^petridis-roles]) and Jeremy Bicha (Canonical engineer, Ubuntu/Debian GNOME packager) modify the XLibre wiki page to label the Xorg fork a “Nazi project” / “Nazi bar.” XLibre is the engineering fork of Xorg that GhostBSD shipped as default in its 26.1 release, a fork led by a developer they politically disagree with.[^xlibre-deface]
  • October 2025: Framework Computer announces sponsorship of the Hyprland project and continues promoting Omarchy (DHH’s Arch + Hyprland distribution). The GNOME OS Team responds by stating that Framework “supports Fascist and Racist s***heads” and that GNOME “does not feel comfortable in further collaboration.” The GNOME Foundation reportedly opens lines to other projects considering disengaging from Framework over the same issue.[^gnome-framework]
  • November 2025: Petridis publishes “DHH and Omarchy: Midlife crisis” on blogs.gnome.org, framing DHH’s politics as the “alt-right pipeline” and characterising the Framework partnership as alignment with that pipeline.[^petridis-omarchy]
  • April 2026: After Framework ships another pre-release Panther Lake laptop to DHH, Petridis posts on Mastodon: “Framework listens to their audience so much that they send another pre-release laptop to DHH. Enjoy the new Nazibook 13 pro.”[^petridis-nazibook]

Two patterns are worth pulling out. First, the Bernard and Petridis posts are hosted on blogs.gnome.org itself (the project’s own infrastructure) not on personal sites the project could disclaim. Allowing them there is an editorial choice. Second, GNOME’s CoC was the same instrument used to remove Sonny Piers from the project in 2024 over a private dispute the Foundation has refused to publicly detail. The CoC moves quickly when a board member becomes inconvenient and not at all when prominent maintainers publicly call paying customers and partner companies Nazis. That asymmetry is the actual signal.

This matters for choosing a desktop because GNOME ships as the default on Fedora Workstation, Ubuntu, Pop!_OS, Zorin OS, and Debian. If you pick GNOME you are picking, by gravitational rather than direct payment, the project that produces this output. KDE Plasma, XFCE, Cinnamon, MATE, and LXQt have not generated comparable patterns. If GNOME’s politics are not something you want to subsidise culturally, the desktop choice is where you can opt out without giving up the distro you otherwise want.

The XLibre / ArchWiki case (April 2026): a contrast

A useful contrast case landed in April 2026. On 15 April, ArchWiki administrator Alad deleted the XLibre project page; the next day, XLibre published a reflection post on X framing the action as part of an “abuse of CoCs as Codes of Censorship,” disclaiming xlibre.net as outdated, identifying the GitHub README as the project’s authoritative source, and announcing plans to “become louder” on CoC abuse, build a central place for third-party XLibre Arch packages, and possibly “liberate some information.”[^xlibre-arch-deletion][^xlibre-reflection] The framing wants to slot the deletion into the same bucket as the GNOME pattern above. It doesn’t fit, and the reasons it doesn’t fit are worth naming.

Arch’s stated basis was the Arch CoC’s “Respect” clause, which prohibits “maligning other FOSS projects or distributions, or any other operating systems and their users.”[^arch-coc] The cited content is real and still live: xlibre.net’s About page calls Xorg contributors “toxic elements” and “moles from BigTech,” and closes with the slogan “Together we’ll make X great again!” XLibre’s response argues that xlibre.net is outdated and that the GitHub README (from which the “moles” passage was removed in commit 4839966 on 25 July 2025) is the only authoritative source. The site is the project’s own domain, runs the project’s own copy, and is what a reader Googling “xlibre” lands on. Disclaiming a domain you control while leaving its content in place does not move the rhetoric to a different actor.

The other half of the post’s framing is that the Arch CoC is being “applied outside of Arch Linux.” Arch removing content from Arch’s own wiki is the CoC being applied inside Arch, hosting decisions on community-run infrastructure are exactly where CoCs operate. The page was not removed from XLibre’s GitHub. The xlibre-server package was not removed from the AUR. Arch chose not to host a documentation page about a project whose public-facing site contains the kind of language Arch’s rule was written to exclude.

This matters for the capture-risk frame because it cuts the other way from the GNOME case. The GNOME pattern is asymmetric application: the same CoC that removed Sonny Piers in 2024 has not moved on prominent maintainers calling paying customers and partner companies Nazis on the project’s own blog network. The Arch case is symmetric application: a stated rule against maligning other FOSS projects, applied to a project that publicly maligns another FOSS project on its own homepage. You can dislike either CoC regime on principle, but treating the two cases as instances of the same problem flattens a distinction the underlying record actually makes. XLibre is also separately a serious engineering project, the GhostBSD entry below shows an engineering-led BSD adopting it as default specifically over governance turbulence at X.Org, and that judgment stands on technical grounds independent of how the project’s public-facing rhetoric has been received elsewhere.

How different commentators sort distros

Different people filter distros through different lenses. Naming the lenses explicitly helps, because a recommendation makes more sense when you know what it’s optimising for.

Capture-risk lens (this doc’s primary frame). Who owns this project’s governance, and what could change it? Corporate distros score worse because they can be acquired, monetised, or rebuilt around revenue (Red Hat → IBM, elementary OS’s funding struggles, Canonical’s Snap enclosure). Foundations and democratic projects score better (Debian’s constitution, OpenMandriva’s French 1901 non-profit, the BSDs’ permissively-licensed codebases maintained by independent foundations). The systemd governance story sits at the centre of this lens because it shows the Iron Law at work inside infrastructure most users never see.

Project-culture lens (Bryan Lunduke and adjacent voices). Does project leadership publicly stake the project on identity-politics or DEI commitments that could override technical merit in hiring, moderation, or roadmap decisions? Lunduke’s August 2025 “non-woke software list” names OpenMandriva (his personal daily driver), GhostBSD, Omarchy, and Devuan as his picks on this axis, and he is critical of projects where he reads the leadership’s signalling the other way (Mozilla, NixOS after the 2024 governance fight, the recent Debian DPL election). Overlap with the capture-risk lens is real (both flag Mozilla, both flag corporate-IBM Red Hat) but the lenses diverge. Debian scores well on capture-risk (democratic, dispersed, cannot be bought) and ambiguous-to-critical on the project-culture lens depending on how recent governance fights read to you. Fedora scores badly on both.

Technical-pragmatism lens (Greg Kroah-Hartman, Linus Torvalds, most working kernel developers). Which distro lets me get work done with the least friction? Torvalds uses Fedora because it stays current and out of his way. Kroah-Hartman uses Arch for the same reason plus the wiki. This lens is indifferent to governance and culture; it only cares whether the distro ships what the user needs and stays out of the way.

Libre/freedom lens (FSF, Trisquel, PureOS, RMS). Does the distro ship only free software, and does it refuse non-free firmware even when that means hardware doesn’t work? A small but principled axis. Most readers will find it too strict for daily use; a few will find it the only serious axis.

These lenses are not a hierarchy. They’re filters. A reader whose primary concern is cultural drift in a project will weight Lunduke’s lens heavily; a reader whose primary concern is state or corporate capture will weight the capture-risk lens heavily; a reader whose job is kernel work will weight the pragmatism lens heavily. The distros that score well on more than one lens (Debian, Devuan, OpenBSD, GhostBSD, OpenMandriva) are the ones that tend to show up on multiple people’s lists for different reasons.

Sovereignty-minded figures and their distro choices

Who uses what, when the person has the technical literacy to choose deliberately. Treat specific-person endorsements as dated quickly and re-verify before leaning on any of them.

  • Linus Torvalds: Fedora. His stated reasoning is roughly “I want a distribution to be easy to install, so that I can just get on with my life, which is mostly kernel.” He moved off openSUSE to Fedora in 2020 and has criticised Debian as “too technical.” Pragmatic-lens, indifferent to governance.
  • Greg Kroah-Hartman: Arch Linux since 2019, including for his team’s cloud instances. His reasoning: “Their idea of a constantly rolling, forward-moving system is the way to go. It’s neutral, it’s community-based, it has everything I need.” He calls the Arch Wiki “amazing.” Keeps test environments on Fedora, Debian, and Gentoo for kernel QA.
  • Lennart Poettering: spent fourteen years at Red Hat, joined Microsoft in 2022, now reportedly also involved in Amutable GmbH. His distro use has followed his employer.
  • Miguel de Icaza: GNOME co-founder, switched to macOS in 2013 and has been a vocal critic of Linux desktop fragmentation ever since.
  • Bryan Lunduke: OpenMandriva as daily driver (since late 2024 / early 2025). Uses a Mac for video production on The Lunduke Journal. Has vouched for GhostBSD, Omarchy, and Devuan as the alternatives on his project-culture axis.
  • David Heinemeier Hansson (DHH): Omarchy, which he created and ships publicly via the Basecamp GitHub org. Arch + Hyprland + full-disk encryption, keyboard-driven, developer-focused.
  • Luke Dashjr: Gentoo and custom builds, prioritising maximum control for Bitcoin-related work.
  • Adam Back: air-gapped systems, typically Debian-based or custom builds.

Among Linux kernel developers as a group, informal reporting puts Fedora at roughly 45%, Arch at roughly 30%, Ubuntu at roughly 15%, openSUSE at roughly 7%, and Gentoo at roughly 3%. Fedora dominates because Torvalds uses it and because Red Hat employs many kernel maintainers. Among serious Bitcoin operators running cold storage or signing hardware, informal community surveys suggest roughly Debian 35% / Arch 25% / Ubuntu 20% / specialised (Qubes, Tails) 15% / Mint 5%, treat those numbers as indicative, not measured.

A mental shorthand before the per-distro entries

If you want a light mnemonic to keep the families straight before we go distro by distro: the kernel is the bean, and every distro is a different way of preparing it. Debian is the dark roast everyone else blends from. Red Hat and Fedora are the corporate espresso. Arch is the pour-over you make yourself and then tell people about. Gentoo is roasting your own beans. Slackware is your grandfather’s percolator. Void is the small-farm import the serious coffee person prefers. Ubuntu and Mint are the friendly café with consistent service, the one most people start at. Windows is instant coffee.

The analogy breaks down where all analogies break down, but it’s enough to hold the shape of the landscape in your head before we go through it one entry at a time.

Debian

The universal operating system. If you don’t know what to pick long-term, this is usually the answer.

Short version for newcomers: Debian is the slow, stable, conservative grandparent of most Linux distros you’ve heard of. Ubuntu, Mint, Kali, MX, Raspberry Pi OS, and dozens of others are built on it. When you learn Debian, you understand the foundation every other apt-based distro is papering over.

Built on: its own packaging ecosystem (dpkg, apt), developed since 1993. Governed by a formal constitution and an elected project leader, not owned by any company.

Licence: FOSS, with non-free firmware for WiFi and GPU support available in a separate opt-in repository.

Good for: servers, long-lived desktops, anything you install once and ignore. The stable release cycle is roughly every two years with about five years of support (three years from the project plus two from LTS). The package repository is vast (~60,000 source packages) and quality is consistently high. A few things worth naming specifically that tend to get lost when people compare distros:

  • apt and dpkg are mature and battle-tested. The packaging system has been refined for nearly thirty years. Dependency resolution works. Package metadata is reliable. Edge cases that bite users on newer or less-standardised package managers rarely bite you here.
  • No corporate agenda can be bolted on. Red Hat / Fedora answer to IBM. Ubuntu answers to Canonical. openSUSE answers to SUSE. Debian is governed by its developers under the Debian Social Contract and the Debian Free Software Guidelines. No company can decide to change the licence terms, sunset your version early for revenue reasons, or push a product you didn’t ask for. Debian cannot be acquired.
  • Security response is serious. The Debian Security Team issues advisories and patches promptly. The frozen nature of stable dramatically shrinks the attack surface compared to rolling distros where new code constantly enters the system.
  • Debian runs everywhere. The same distro runs on x86, ARM, RISC-V, MIPS, POWER, and more architectures than any other general-purpose Linux distribution. If you ever move to embedded systems, servers, or exotic hardware, your knowledge transfers directly.

Bad at: shipping recent desktop software. Debian stable is conservative by design, your GNOME or KDE will be 6–18 months behind upstream. Backports help; Flatpak fills the rest. For cutting-edge desktop software, run Debian testing/sid or use Flatpak aggressively. This is a deliberate trade, and for most people the stability is worth more than the freshness.

Trajectory: stable and essential. Debian 13 “Trixie” released August 2025, current stable through roughly 2028. Not going anywhere.

Use if: you want the universal default, don’t want to be on a corporate Linux, and value longevity over freshness.

Ubuntu

Canonical’s commercial Debian derivative. Historically the default beginner distro, now a cautionary tale. See What not to pick in 2026.

Short version for newcomers: Ubuntu is Debian repackaged by a company called Canonical, with some of their own additions on top. For a long time it was the friendliest way into Linux. Over the last few years it has gotten heavier, more corporate, and less aligned with what most users actually want, to the point that Linux Mint (which is built on Ubuntu) strips out Ubuntu’s additions on install.

Built on: Debian, plus Canonical’s own layers (Snap, Livepatch, Ubuntu Pro).

Licence: FOSS, including non-free firmware by default for broad hardware support. Some Canonical tooling is source-available but governed by contributor agreements that assign rights to Canonical.

Good for: matching production cloud environments, running on hyperscaler images, following most third-party Linux tutorials without translation. LTS releases (every two years) are supported ten years with Ubuntu Pro (free for personal use).

Bad at: being Debian. Snap is Canonical’s proprietary-backend package system that silently replaces apt install targets for certain popular packages (apt install chromium has installed a Snap for years). Snap performance is noticeably worse than native or Flatpak for many apps. Telemetry is on by default. GNOME is modified in ways that fight upstream. And in 2026, Ubuntu Desktop requires 6GB RAM and a 2GHz dual-core CPU, more than Windows 11 asks for[^ubuntu-req]. As a directional comparison on overall weight, Bryan Lunduke noted in April 2026 that the Ubuntu 24.04 LTS desktop ISO is roughly 6.3 GB while the Windows 11 ISO is roughly 5.8 GB: the free “lightweight alternative” now ships a larger installer than the proprietary OS it was pitched against. ISO size is not runtime weight, but the trend line on both axes (ISO and RAM) points the same direction. The March 2026 CVE-2026-3888 disclosure, which allows local privilege escalation via the Snap-systemd-tmpfiles interaction in default installs, is the most visible recent symptom of that architecture[^cve-3888].

Trajectory: still the largest deployed desktop Linux by install count but visibly losing enthusiast mindshare to Mint, Fedora, and Arch.

Use if: you need Ubuntu specifically (a work requirement, a vendor only ships .deb packages for Ubuntu) and can tolerate the direction. Otherwise use Debian for desktop or server.

Linux Mint

The most Windows-like Linux landing, and for years the reflexive recommendation for non-technical friends leaving Windows. Cinnamon desktop by default, with XFCE and MATE editions. Based on Ubuntu LTS but with Snap completely removed and replaced with Flatpak. This guide no longer makes it the default first install (Debian gets that; see the TL;DR and Decision shortcut), and the caveats below are why.

Short version for newcomers: Mint is what happens when a small team takes Ubuntu, strips out the parts of Ubuntu they don’t like, and ships a friendly, Windows-looking desktop on top. For someone leaving Windows today, Mint is the easiest landing. You’ll be productive in an hour.

Built on: Ubuntu LTS, with an insurance-policy branch (LMDE, Linux Mint Debian Edition) that builds directly on Debian instead of Ubuntu.

Licence: FOSS, including non-free firmware by default for broad hardware support.

Good for: people migrating from Windows. Cinnamon is the closest Linux comes to a traditional Windows layout. The update manager is unintimidating, the community forum is friendly, and the project’s identity is partly built on saying no to Canonical’s Snap push.

Bad at: being cutting edge. You’re on an Ubuntu LTS base, so packages are conservative. Not ideal if you need the latest developer toolchains or very recent hardware support.

Flags (read this if you install Mint): An independent Wireshark-based analysis in February 2025 found no OS-level telemetry from Mint, no analytics, no Canonical callbacks, no hidden logs beyond basic update-server contact[^mint-telemetry]. That’s the good news. The less-good news: on 22 December 2025, a bug report filed on the official linuxmint/cinnamon GitHub repository (issue #13298) documents the Cinnamon process on Mint 22.2 leaking memory from a clean 3GB post-boot footprint up to 70% of 32GB RAM over about four hours of normal use, forcing the user to run cinnamon --replace twice a day as a workaround[^mint-leak]. As of this writing (four months later) the issue is still open, still unassigned, and labelled only as a bug. Multiple other users have reported similar symptoms across forum threads during the same window. If you installed Mint 22.2 during the Windows 10 migration wave and felt your machine getting slower over the winter, you were probably not imagining it. Either a cross-machine memory leak exists in shipped Cinnamon and the Mint team has not addressed it in four months, or something else in the 22.2 release is regressing on real workloads. Either possibility matters. The structural point, separate from this specific bug, is that Mint has become the most-recommended Windows-escape distro on the internet, which makes it the most obvious capture target for anyone who wants to bend a large downstream user base. Mint is currently clean, but a shipped memory-leak regression left unaddressed for months is a quality-control signal, not just one bug, and a project’s quality-control slipping while its install base balloons is exactly when to be cautious rather than to make it the default. That is why this guide now points first-time migrants to Debian and keeps Mint as the choice for readers who weight Windows visual familiarity above everything else. Watch the project the way you should watch anything that gets that popular.

Trajectory: strong growth post-Windows-10-EOL. The project has publicly said an LMDE-only future is possible if Canonical ever poisons Ubuntu beyond the point where Mint can work around it.

Use if: Windows visual familiarity is your single overriding priority and you want the fastest cosmetic match with no homework. Otherwise start on Debian, the default this guide now recommends for migrants. If you do start on Mint, plan to move to Debian or Devuan in 6–12 months as you get comfortable.

MX Linux

Debian-based, developed jointly by the MX Linux team and the antiX community since 2014. Consistently top-three on Distrowatch’s most-visited-distros listing for the past five years. Ships sysvinit by default with optional systemd available at boot, meaning the same distro can run either init system depending on what you pick at the boot menu. That property alone makes MX interesting on a sovereignty axis: a Debian-based distro that does not force the systemd choice on you.

Built on: Debian Stable base plus MX-specific repositories that add updated packages where the Debian Stable version is too old to be practical (Firefox, kernel, some user-facing apps). The default desktop is XFCE; KDE Plasma and Fluxbox editions also exist.

Licence: FOSS, same disposition as Debian Stable (which it inherits from). Non-free firmware is enabled by default for hardware support.

Good for: a working desktop that’s faster than Mint on older hardware, more current than pure Debian Stable, and gives you the sysvinit option without leaving the Debian ecosystem. The MX-tools suite (a collection of graphical utilities for system administration, backup, package management, kernel selection, snapshot creation) is genuinely useful and one of the project’s distinctive features.

Bad at: nothing in particular for general desktop use. The MX-specific repositories are an additional trust surface compared to pure Debian; not a deal-breaker, but worth knowing.

Trajectory: stable, growing slowly. The combination of Debian-base plus sysvinit-by-default plus active maintenance is rare enough to keep the project alive without needing to chase trends.

Use if: you want a Debian-based desktop that doesn’t force systemd, you want better-than-Stable package freshness without going to Testing, or you want MX-tools’ graphical-administration suite. Acts as a less ideologically-loaded entry into the no-systemd world than Devuan or Artix.

Fedora

Red Hat’s community-facing distribution, upstream of RHEL. Cutting-edge but carefully tested. GNOME by default; KDE Plasma, XFCE, and others are first-class spins.

Short version for newcomers: Fedora is the Linux that developers and Mac refugees tend to end up on. It gets new features first (Wayland, Pipewire, systemd-homed, btrfs) and is more polished out of the box than most alternatives. It is also directly owned by Red Hat, which is owned by IBM, which you may or may not care about.

Built on: its own RPM-based packaging, dnf.

Licence: FOSS, with historically aggressive patent-avoidance. Fedora won’t ship MP3 support or certain codecs until their patents expire; RPM Fusion is the third-party repo for the things Fedora won’t ship. Non-free firmware for hardware support is available but not enabled by default.

Good for: developers, GNOME fans, anyone who wants recent packages with real QA. Fedora 43 released late 2025; Fedora 44 expected April–May 2026. Fedora Atomic variants (Silverblue, Kinoite, and the Bazzite-adjacent stack) are the interesting frontier, these use the immutable-base-filesystem model described in the glossary.

Bad at: long-term stability. Every six months you do a major-version upgrade; each release is supported about 13 months. Not for set-and-forget machines. And the upstream parent is IBM, which comes with the CentOS Stream controversy, RHEL source-access restrictions, and the general direction Red Hat has taken post-acquisition.

Trajectory: rising sharply as developers leave Ubuntu.

Use if: you want a Mac-like coherent desktop with current software and you’re willing to upgrade annually.

Arch Linux

The hacker’s distribution. Minimal by default, rolling release (meaning you get new versions of each package as soon as upstream releases them, with no fixed release cycle), you build the system up from a working shell. The Arch Wiki is the best piece of Linux documentation in existence and is used as a reference by users of every other distro.

Short version for newcomers: Not for newcomers. Come back after six months on Mint or Debian. Then the Wiki will reward you.

Built on: pacman (its package manager), plus the AUR, a community-run repository of build scripts that covers roughly 100,000 packages. The AUR is one of the main reasons people use Arch, almost any piece of software you can think of is one command away.

Licence: FOSS. Non-free firmware is available via the linux-firmware package, which most users install.

Good for: people who want to understand their system. You choose every component, init, bootloader, desktop, everything. Rolling release puts you within days of upstream for kernel, Mesa, and desktop versions. Greg Kroah-Hartman (the stable kernel maintainer) switched his team to Arch for the rolling model and the wiki.

Bad at: being forgiving. You read the news before updating or you occasionally break things. Not a good fit on a machine you rely on but don’t want to maintain.

Trajectory: stable and central. The Arch family (Arch + EndeavourOS + CachyOS + Manjaro + Garuda + SteamOS) is probably the second-largest Linux family after Debian/Ubuntu.

The “customise Arch once and use it forever” argument. A strategy experienced users sometimes propose: install Arch, customise it exactly how you want, write a script that rebuilds that setup from scratch, and then run it on all your machines and just maintain it with minor tweaks. It’s a legitimate strategy and plenty of people do it. The honest catch is that your rebuild script can’t freeze upstream. Rolling release means you’re implicitly trusting that every update to every package in the chain plays nicely together, forever. Sometimes it doesn’t, a mesa update breaks Wayland, a kernel update and a GPU driver update land a week apart and conflict, systemd changes something subtle. You can pin packages or run a partial local mirror to truly lock things, at which point you’re doing more work than Debian stable would have required. Plenty of Arch users run this pattern successfully; just know that the “set it up once and forget it” promise is only honoured if breakage is rare enough in your specific setup to ignore, which is a bet you can’t make in advance.

Use if: you want bleeding edge and you’re OK reading the Wiki regularly.

EndeavourOS, CachyOS, Manjaro (Arch derivatives)

Three different takes on “Arch, but with an installer.”

EndeavourOS: the faithful one. Arch plus a graphical installer, a few sane defaults, and a friendly forum. Uses Arch repositories directly (so you get Arch packages at the same time Arch users do) and full AUR access. Once installed it behaves like Arch.

CachyOS: Arch tuned for performance. Custom kernel with scheduler tweaks, binaries compiled specifically for modern CPU instruction sets (x86-64-v3 or v4), gaming-optimised defaults. Measurably faster than stock Arch on recent hardware. Growing fast in early 2026.

Manjaro: do not use. Ships its own delayed repo (Arch packages held back ~2 weeks) that regularly causes breakage with the AUR (because the AUR expects current Arch, not two-week-old Arch), and the team has shipped expired SSL certs on their own website more than once. Strictly worse than both Arch and EndeavourOS.

Use if: EndeavourOS for vanilla Arch with install ergonomics; CachyOS for gaming or performance.

Omarchy

Arch + Hyprland, preconfigured and opinionated. DHH’s (David Heinemeier Hansson, of Rails and Basecamp) answer to “I want Arch’s power but I don’t want to spend three days on dotfiles.” Maintained under the Basecamp organisation on GitHub.

Short version for newcomers: Not a newcomer distro. Omarchy is for someone who already knows what Arch is and wants DHH’s taste applied to it out of the box. If you’re coming from Windows, Mint is your answer, not this.

Built on: Arch Linux. Installs via a dedicated ISO (recommended) or a one-line script on top of a bare Arch install. Uses Hyprland (a tiling Wayland compositor), btrfs, and mandatory LUKS full-disk encryption. Ships Neovim, tmux, Alacritty, Chromium, a curated set of developer tools, and a keyboard-first workflow (no display manager, no login screen after disk-decrypt).

Licence: FOSS. Arch repos plus a small Omarchy repo for the opinionated pieces.

Good for: developers who want a tiling-window-manager workflow with good defaults. Terminal colours, Neovim theming, Waybar, Hyprlock, and the rest all move together when you switch theme, cohesive in a way a hand-assembled setup usually isn’t. Curated themes include Catppuccin, Gruvbox, Tokyo Night, Everforest, Flexoki Light. The Omarchy Menu (Super + Alt + Space) is the main control surface; almost everything happens via keyboard.

Bad at: being flexible about taste. You are installing DHH’s preferences, the fonts, the colour palettes, the editor, the window-manager keybindings, the software choices. Everything is overridable (they’re still config files in ~/.config) but you’re starting from someone else’s aesthetic, not a blank slate. Also: no mouse-first workflow (you literally cannot do much without keyboard on first boot), no support for legacy BIOS (UEFI only), and the installer wipes the target drive, dual-boot is awkward and requires two physical disks.

Trajectory: actively developed, growing community, DHH is personally invested. Current releases in the 3.x series as of early 2026.

Use if: you’re already Linux-literate, you want a tiling-WM developer setup, and you want someone else’s good decisions as the starting point. If your reaction to “mandatory full-disk encryption, keyboard-only, Hyprland-first” is “yes, finally,” you’re the audience.

Devuan

Debian minus systemd. Same repositories, same packaging, same support cycle; just sysvinit / OpenRC / runit for init, and eudev for device management.

Short version for newcomers: Devuan is what you install after you’ve spent a year on Mint or Debian and decided systemd isn’t for you. See The systemd problem.

Built on: Debian.

Licence: FOSS, with the same non-free firmware repository structure as Debian.

Good for: anyone who wants Debian’s ecosystem and stability without the systemd dependency chain. If you know Debian, you know Devuan. The delta is intentional and small.

Bad at: cutting-edge packages. Tracks Debian stable. A small maintainer team means security patches occasionally lag upstream by a day or two.

Trajectory: steady. Devuan 6 “Excalibur” (tracking Debian 13 Trixie) released 2025. The project has delivered on time for every Debian stable release for a decade.

Use if: you want Debian without systemd and you want apt. Lowest-friction non-systemd path for a Debian user.

Artix

Arch minus systemd. Supports OpenRC, runit, s6, dinit. Shares Arch’s repositories and AUR through a compatibility layer.

Built on: Arch Linux.

Licence: FOSS, same model as Arch.

Good for: people who want Arch’s bleeding edge and the AUR without systemd.

Bad at: being as battle-tested as Arch. Fewer users means fewer people have hit and fixed the edge cases. When an AUR package assumes systemd is present, you’re on your own.

Trajectory: small but healthy.

Use if: Arch, but no systemd.

Void Linux

Independent from the ground up. Not based on anything. Its own package manager (xbps), its own init (runit), its own repos, and a choice between glibc and musl C libraries. Rolling release.

Short version for newcomers: Not for newcomers, but worth knowing about. Void is the answer to “what if we built a rolling Linux from scratch, kept it lean, and refused corporate sponsorship?” Small community, small repo, very fast.

Built on: itself.

Licence: FOSS, with non-free firmware available in a separate repository.

Good for: people who want a lean, fast, opinionated rolling distro with no corporate owner, no systemd, no legacy baggage. runit is among the simplest init systems in existence; xbps is noticeably faster than apt or pacman; boot times are short. musl is a real working path if you want a smaller, stricter libc (musl is an alternative to glibc, the GNU C library almost everything else uses, musl is smaller and stricter but some proprietary software expects glibc and won’t run on it).

Bad at: ecosystem size. Fewer packages than Arch or Debian; the xbps-src templates are smaller than the AUR. Small community is also its charm.

Flag worth naming: the 2018–2019 maintainer-absence scare. Void’s original lead developer, Juan RP (xtraeme), disappeared from the project for an extended period in 2018 without warning, no commits, no responses, no handover. For several months the domain registration and infrastructure hung in limbo. The rest of the contributor team eventually organised, moved to a new domain (voidlinux.org instead of the original), and distributed the governance so no single maintainer absence could cause the same problem again. The project recovered, kept shipping, and is in good shape in 2026. But the episode is the single clearest bus-factor warning in the independent-distro space, and the lesson stuck: even a well-run small project can have one person whose disappearance freezes the whole thing. Void’s governance is more distributed now, but the user base is still small, and “what happens if the maintainers lose interest” is a real question for any independent distro.

Trajectory: slow, steady, healthy. Quiet in the way a good distro should be.

Use if: you want something independent, lean, non-systemd, and opinionated. If someone asked me “what’s the distro closest to the spirit of this document,” this is it.

Gentoo

Source-based. You compile everything from Portage, with fine-grained USE flags controlling which features get compiled into each package. OpenRC by default; systemd also supported; rolling release; excellent handbook.

Short version for newcomers: Gentoo is the Linux people run when they want to understand every part of their system and don’t mind waiting for it to compile. Not a first distro, but probably the best learning distro once you’re ready.

Built on: its own build system and ports-style tree.

Licence: FOSS. Non-free firmware is available if enabled via build flags.

Good for: total control. A full browser compile takes hours; a kernel compile is normal. In exchange you get a system with exactly the features you enabled, compiled for your CPU. ChromeOS is Gentoo-derived.

Bad at: fast iteration. Changing a USE flag globally rebuilds everything that depends on it. World updates can run for hours.

Trajectory: stable, small, beloved. Healthy long-term niche.

Rolling done right. Gentoo’s init story is one of the cleanest in mainstream Linux. OpenRC is the default, systemd is supported but never imposed, and the choice is a profile selection at install time rather than a compatibility hack grafted on after the fact. USE=-systemd at the build level keeps systemd’s daemons from being compiled into anything that doesn’t strictly need them, so even on a hybrid system the surface area stays small. Compare to Arch, where systemd is the default and the assumption: shipping the same package set without it requires a parallel project (Artix), because Arch’s tooling and many AUR packages are written assuming systemd is present. Both are “rolling,” but the rolling-distro promise (upstream-fast updates with full control over what your system actually runs) is one Gentoo keeps and Arch only partially does. If init choice and build-level component control matter to you, Gentoo is the only mainstream rolling distro that treats them as first-class.

Use if: you want to understand your system deeply, you have time to compile, and you value control over convenience.

NixOS

Declarative system configuration. Your entire system (installed packages, services, users, network, firewall, cron jobs) lives in a single configuration.nix file. Rebuilds produce a new generation you can roll back to.

Built on: the Nix language and package store.

Licence: FOSS. Non-free firmware and software available via the unfree attribute flag.

Good for: reproducible system configuration, dev environments, anyone who wants infrastructure-as-code for their own machine. Nixpkgs is among the largest package repositories in existence (100,000+). Atomic rollbacks are genuinely useful.

Bad at: being learnable in a weekend. The Nix language is its own thing, documentation has been a long-standing weak spot, and the mental model is different from every other distro.

Flags: significant governance fights in 2024 around the NixOS Foundation, military contracts (Anduril specifically), and CoC enforcement; long-time maintainers left and forked as Lix. The foundation has restructured but the culture isn’t fully settled.

Trajectory: powerful, interesting, mid-reset. Evaluate the Lix fork if governance matters to you.

Use if: reproducibility is your top priority and you’re willing to learn a new language for it.

Slackware

The oldest surviving Linux distribution still actively developed. Released in July 1993 by Patrick Volkerding, who remains the sole maintainer. Did not adopt systemd, does not plan to, never will. Used by the contributor who taught Linus Torvalds about Linux. Distinct enough that almost nothing else looks like it.

Built on: its own package format (.tgz / .txz tarballs) and pkgtool for management. No automatic dependency resolution by default, you read the documentation, install what’s needed, and the system gets out of your way. Third-party tools like slackpkg and slackbuilds.org add dependency resolution if you want it.

Licence: FOSS. Conservative on accepting new code; the project’s stated value is stability and predictability over feature pace.

Good for: a sysvinit-style boot (actually BSD-style init scripts, predating sysvinit’s later refinements), no telemetry, no scheduled upgrades, no community drama because the project is run by one person who has zero interest in being captured by anything. Slackware 15.0 released February 2022; Slackware-current is the perpetual rolling beta where new packages land. Stable releases come “when ready,” which has historically meant 3-6 years between major versions.

Bad at: hand-holding. Slackware assumes you can read documentation and configure things yourself. New users typically struggle with the no-dependency-resolution default; tools like slackpkg reduce the friction. The wider ecosystem of “official Slackware spins” is non-existent, there’s just Slackware, full stop.

Trajectory: stable in the literal sense. The project has outlived most of its peers and shows no sign of changing course.

Use if: you want the oldest, most conservative, most stable Linux project, run by one person who doesn’t take corporate funding and doesn’t take input from culture war factions of any political direction. Niche but real. Not recommended for newcomers.

openSUSE Tumbleweed and Leap

SUSE’s community distribution in two flavours: Tumbleweed is rolling; Leap is periodic-release aligned with SUSE Linux Enterprise.

Built on: SUSE’s packaging (RPM, zypper, YaST).

Licence: FOSS, with non-free firmware and codecs available in opt-in repositories.

Good for: Tumbleweed is the best-QA’d rolling release available, uses openQA, an automated testing harness, so packages don’t land on users’ machines until a large matrix of installation and upgrade scenarios has passed. Leap is conservative enterprise-style. YaST is the most polished graphical system-administration tool in any major distro. KDE Plasma is first-class. Btrfs (a modern filesystem) with snapshots by default, so bad-update rollbacks are trivial.

Bad at: mindshare in the US. More popular in Europe, so English-language tutorials often default to Ubuntu or Fedora. SUSE’s corporate ownership has changed hands multiple times.

Trajectory: stable and underrated.

Use if: you want a rolling release that has actually been tested before it hits you.

OpenMandriva

The community continuation of Mandrake / Mandriva, maintained by the OpenMandriva Association, a non-profit established 12 December 2012 under French 1901 associations law. RPM-family, KDE Plasma by default, Clang-built, community-governed. Structurally unusual in that it cannot be acquired: the Association has a member assembly, an elected council, and published bylaws that include dissolution rights. Bryan Lunduke runs it as his personal daily driver and it’s what brought OpenMandriva into wider view on the project-culture axis in 2025.

Short version for newcomers: An option you probably haven’t heard of unless a specific commentator sent you. Possible but not the first Linux to try if you’re coming straight from Windows, the community is smaller, documentation is thinner, and Mint will be easier for your first six months. Worth knowing exists for when you want a non-corporate RPM alternative to Fedora.

Built on: its own RPM-based packaging, DNF package manager (same family as Fedora, openSUSE, Mageia). Built with Clang rather than GCC, the first major desktop Linux distribution to make that switch, in 2016. Ships x86_64 builds plus separate znver1-optimised builds for AMD Zen. Two release channels you must not mix: ROME (the rolling release, for individual users, current iteration published 11 December 2024) and Rock / OMLx 6.0 (the fixed point release, codename Vanadium, published 20 April 2025). Rock 6.0 ships KDE Plasma 6 with X11 or Wayland sessions and alternate LXQt, GNOME, XFCE, and COSMIC 1.0 alpha spins.

Licence: FOSS. Non-free firmware is available.

Good for: someone who wants an RPM-family distro without Fedora’s IBM-Red Hat parentage or openSUSE’s SUSE-corporate layer. KDE Plasma is first-class and feels closer to a Windows-like layout than GNOME. Proton and Proton Experimental are available directly from the repos, which means Steam / Windows-game compatibility is an dnf install away without adding third-party repos. Governance is structurally clean: French 1901 non-profit, published council, treasurer and bureau listed publicly, annual activity-and-accounts reports required.

Bad at: scale. DistroWatch hit-rank bounces around the 30s–40s, roughly an order of magnitude less traffic than Mint, Debian, or Fedora. Smaller forum, fewer tutorials, thinner third-party package coverage. Uses systemd as default init, if your primary objection is systemd, OpenMandriva doesn’t solve it (a runit package exists in repos but swapping init is DIY and unsupported). The ROSA-lineage part of its ancestry connects back to a Russian Mandriva fork, which is worth naming given the supply-chain frame elsewhere in this guide; the current Association is French and governed independently, but the code history is what it is. Not a gaming-specialised distro in the Bazzite/CachyOS sense, Proton in the repo is a package, not a tuned stack.

Trajectory: steady. Rock 6.0 released on schedule in April 2025. ROME updates continuously. The Association has not had a visible governance crisis. It remains small.

Use if: you want a non-corporate RPM-family distro with a Windows-like KDE desktop, you are OK being on a smaller distro with thinner documentation, and either governance posture or Lunduke’s endorsement is what brought you here.

Pop!_OS

System76’s Ubuntu-based distribution. Currently shipping with a heavily-modified GNOME; the COSMIC desktop (a Rust-based rewrite) is in alpha/beta as of early 2026.

Built on: Ubuntu LTS.

Licence: FOSS, including non-free firmware and NVIDIA drivers by default.

Good for: System76 hardware, NVIDIA GPU users (Pop ships NVIDIA drivers out of the box), tiling-friendly workflow via GNOME extensions.

Bad at: timing. The COSMIC transition has been slow; shipping Pop is in a holding pattern.

Trajectory: in transition. If COSMIC lands well, Pop becomes interesting again.

Use if: you bought a System76 machine, or you’re on NVIDIA and want a curated Ubuntu derivative.

Zorin OS

Commercial Windows-refugee distribution. Heavy migration tooling, Windows-like default layouts. Free core; Zorin Pro adds more layouts and preinstalled apps.

Built on: Ubuntu LTS.

Licence: FOSS core, including non-free firmware for broad hardware support; Zorin Pro is a paid tier with additional layouts and apps but no core lock-in.

Good for: people nervous about leaving Windows who want maximum hand-holding. Zorin 18 shipped October 14, 2025 (the same day as Windows 10 EOL) with explicit Windows-installer detection, OneDrive integration, and Windows-desktop layouts. The project reported roughly 100,000 downloads in the first two days, most from Windows-origin machines.

Bad at: being interesting. Under the hood it’s Ubuntu. The polish is genuine but you’re paying for curation rather than anything architecturally different.

Trajectory: rising on the Windows-10-EOL wave.

Use if: you need to migrate a non-technical family member and want the least-friction onboarding. Mint is the free alternative.

Bazzite and SteamOS (gaming)

Bazzite: community-maintained Fedora Atomic variant with Steam, Proton (the Valve tool that lets Windows games run on Linux), gamescope, and HDR/VRR preconfigured. Desktop and handheld variants. Uses the immutable-base-filesystem model, the core OS is read-only and updates atomically, applications live in Flatpak or Distrobox containers. Practically: you can’t easily dnf install a system package, but you also can’t break your system by accident, and rollbacks are trivial.

SteamOS: Valve’s own Arch-based distribution, officially shipping on the Steam Deck with broader desktop release expected. Bazzite is the practical choice for non-Deck hardware today.

Licence: FOSS, including non-free firmware, NVIDIA drivers, and codec support needed for gaming.

Good for: gaming hardware you want to think about as little as possible. Proton runs a large majority of top Steam titles as of 2026. HDR and VRR work on supported hardware.

Bad at: being your main development machine. Immutable base means traditional apt install / dnf install doesn’t apply; you work through Distrobox, Flatpak, or Toolbx.

Use if: you want a gaming PC that isn’t Windows 11.

Alpine

Minimalist, security-focused, musl-based. Famous as the default base for Docker containers (~8MB image vs 200MB+ for Ubuntu) but also usable as a server or desktop OS.

Built on: musl libc, BusyBox, OpenRC, its own package manager (apk).

Licence: FOSS. Proprietary firmware blobs are typically not installed by default.

Good for: containers, embedded, small self-hosted servers, security-minded users who want a small attack surface. Position-Independent Executables by default, stack-smashing protection on, minimal base install.

Bad at: desktop use by default. Because Alpine uses musl instead of glibc, some proprietary software that assumes glibc won’t work, some games with anti-cheat, some enterprise software, a few Steam titles.

Use if: you run containers or small servers and want the smallest most-audited base.

High-security operating systems: how to pick

The next several entries (Qubes, Whonix, Tails, Kicksecure, Vendefoul Wolf, Trisquel/PureOS) are operating systems built specifically to defend against serious threat models. Most people don’t need them; the entries above (Debian, Devuan, Fedora, Mint) plus the workstation hardening covered separately get you to a perfectly defensible setup for normal use. If you’re considering one of these high-security distros, you’re answering a specific question: “what’s the right system for this scenario?”

The six scenarios that actually drive these choices, and the picks that map to each:

A. One-off amnesic session for sensitive work. Booting from a USB stick, doing one specific thing (filing a report, accessing a sensitive account, communicating once), and rebooting back to your normal life with nothing persisted on disk. The system must leave no forensic trace on the host hardware. Pick: Tails. Designed for exactly this. Boots from USB, runs entirely in RAM, all network traffic forced through Tor by default, optional encrypted persistent volume only if you opt in.

B. Persistent Tor-anonymous workspace. You’re working on a project (research, journalism, contributing to a sensitive codebase) over weeks or months under a pseudonym, and every connection it makes should be over Tor with no way for a misconfigured application to leak your real IP. Pick: Whonix, ideally on Qubes. The Whonix-Gateway VM forces all traffic from the Workstation through Tor at the network layer; even a compromised application inside the Workstation cannot leak your real IP because it has no non-Tor route. Qubes-Whonix adds whole-machine compartmentalisation underneath.

C. Compartmentalised daily driver. Your main computer is used for work, banking, personal stuff, untrusted browsing, code from random repos. You want an exploit in your browser not to compromise your banking session. Pick: Qubes OS. Each role runs in its own VM. The browser qube getting owned doesn’t touch the work qube. Requires real hardware (16GB+ RAM, virtualisation extensions, whitelisted laptop) and a learning curve, but no other OS makes per-task isolation this thorough.

D. Hostile-network field work. You’re travelling and connecting through coffee-shop Wi-Fi, hotel networks, conference networks, foreign infrastructure that may be hostile. Pick: depends on whether you’re carrying a hardened daily-driver laptop or a fresh machine. If carrying your hardened Devuan workstation, add a Whonix-Gateway VM in front of its networking for the duration of the trip (forces Tor on top of existing hardening). If carrying a fresh laptop and want minimal forensic footprint, Tails on USB. If carrying Qubes hardware, Qubes with sys-net-vpn for the entire trip.

E. Air-gapped signing or cold storage. A machine that holds high-value cryptographic material (GPG primary key, Bitcoin wallet keys, code-signing keys) and never connects to the internet. Pick: hardened Devuan or Debian with networking physically removed (no Wi-Fi card, no Ethernet cable), or Tails for sessions you don’t want persisted, or OpenBSD when OS monoculture is itself part of the threat model. The OS choice matters less than the air-gap discipline. OpenBSD as a signing-machine choice has a specific argument behind it: it’s not derived from anything else this doc covers, its codebase is small enough that one person could in principle audit it, and its developers prioritise correctness over feature pace in a way that matches the air-gapped-signing role. Full OpenBSD coverage is in the BSDs section below.

F. Hardened daily driver that isn’t a Tor target. You want a normal daily-use desktop with strong defaults (disk encryption, hardened kernel, sensible permissions, integrity monitoring) without the cost of running everything through Tor. Pick: hardened Devuan, Kicksecure, or Vendefoul Wolf. All three reach roughly the same end state; the difference is whether you want to apply the hardening manually (Devuan + the workstation hardening guide) or inherit it from the distro (Kicksecure, which is Debian-based with hardening defaults; or Vendefoul Wolf, which is Devuan-based with similar defaults).

Layering: what stacks with what

Qubes-Whonix is the strongest desktop setup available. Whonix-Workstation templates run as qubes; Whonix-Gateway runs as a separate qube; other qubes (work, personal, banking) use the Gateway as their network proxy or have direct internet depending on role. The thing Qubes-Whonix is genuinely best at: a browser exploit in the Whonix-Workstation qube cannot leak your real IP (Whonix-Gateway blocks the route) AND cannot reach your work qube’s files (Qubes blocks the cross-qube access).

Whonix on a non-Qubes Linux host is the fallback when Qubes hardware isn’t available. KVM or VirtualBox on a hardened Devuan or Debian host. Worse than Qubes-Whonix because the host OS is now the trust boundary; a host compromise compromises the Whonix session. Better than running anonymous work directly on the host.

Tails on a personal laptop versus Tails on a borrowed machine is a meaningful distinction. The personal laptop has consistent hardware identifiers (MAC addresses, hardware serial numbers) that, while masked by Tails defaults, persist across sessions. A borrowed machine breaks that linkage; an internet café or library terminal that you don’t return to is the strongest version of “amnesic session.”

Hardened Devuan plus Whonix-Gateway is the lower-commitment version of compartmentalised + Tor-forced. Your daily driver runs on Devuan with the standard hardening; a Whonix-Gateway VM exists on the same host; specific applications (a separate Firefox profile, a specific user account, a Qubes-style “anon qube” that’s actually just a separate VM) route through the Gateway. Most work runs on the direct-internet Devuan host with the host’s hardening; the Tor-routed activity uses the specific Gateway-routed path.

Heads or coreboot underneath any of the above. The firmware layer (BIOS / UEFI) is below the OS, and every OS on this page assumes the firmware loading it is benign. If your threat model includes physical access to your hardware or supply-chain interception, replacing the proprietary firmware with Heads (an open coreboot payload that measures and verifies the boot chain) closes the layer everything else assumes. Hardware-specific; supported laptops include the ThinkPad X220/X230/T420/T430 lineage, Purism Librem, System76 with open firmware, and Dasharo-supported boards.

Hardware requirements at a glance

  • Tails: any laptop with 2GB+ RAM that boots from USB. Forgiving.
  • Whonix on Qubes: Qubes hardware requirements (16GB+ RAM, virtualisation extensions, whitelisted laptop). Tight.
  • Whonix on Linux host: any modern laptop with 8GB+ RAM and KVM/VirtualBox support. Forgiving.
  • Qubes: 16GB+ RAM minimum, 32GB comfortable, Intel VT-x with VT-d or AMD equivalents, SSD, qubes-hcl whitelist match. Specific.
  • Kicksecure or Vendefoul Wolf: any laptop that runs Debian or Devuan. Forgiving.
  • Heads / coreboot underneath: ThinkPad X230/T430/X220/T420 (or Purism / System76 / Dasharo). Specific.

Threats these systems do not address

Worth being explicit. The high-security distros above defend against software-level threats: browser exploits, malicious applications, network surveillance, forensic recovery from disk. They do not defend against:

  • Physical coercion. “Unlock this device or you’re not getting out of this room” defeats encryption. Defense is jurisdictional (be where coercion is illegal) or operational (plausibly-deniable hidden volumes, duress passwords that wipe), not technical.
  • Side-channel attacks on the hardware. Power analysis, electromagnetic emanations, acoustic cryptanalysis. Defense is physical (Faraday-shielded room) or hardware-level (open silicon like Precursor), not OS-level.
  • Stylometric fingerprinting. Your writing style identifies you across pseudonyms. The Whonix wiki explicitly warns about this. No OS can fix it; it’s a behavioural discipline.
  • Time-correlation attacks on Tor. A global passive adversary observing both ends of the Tor network can correlate your traffic. Defense requires more than Tor alone (operations-time discipline, mixing in additional latency, sometimes running operations on a delay).
  • Targeted hardware implants. A nation-state-level adversary who has interdicted your hardware shipment has access below the OS layer. Defense is supply-chain (buy openly, verify firmware on receipt, prefer hardware you can audit) and physical-security (don’t leave the laptop in hotel rooms when travelling).

If your threat model includes any of these, the OS choice is necessary but not sufficient. Behavioural discipline and physical-security practice matter more than which Linux you picked.

Other “anonymous” distros worth knowing why to skip

  • Kodachi. Advertised as a “secure anti-forensic Linux,” but per a bitsex.net technical review, structurally Ubuntu with theming and shell scripts rather than a system-level hardened distro the way Tails is. The hardening claims don’t survive inspection. Skip.
  • Subgraph OS. The original project went dormant; the team’s successor project, Citadel, has not produced production releases as of late 2025. Promising design (Grsecurity-kernel, OZ application sandboxing) but not currently a working option. Wait and see.
  • Parrot OS, Kali Linux. Penetration-testing distros, not user-privacy distros. They’re configured to be useful tools for someone doing offensive security work, not to defend the user running them. Different problem; wrong tool.
  • HardenedBSD. Real hardening work on FreeBSD (ASLR, W^X, segvguard). Worth considering if you’re already a FreeBSD user. For someone coming from a desktop-Linux background, the BSD learning curve is its own commitment; Tails/Whonix/Qubes on Linux are closer to where you already are.

Qubes OS

Security through compartmentalisation. Built on the Xen hypervisor; every application runs in its own virtual machine (a “qube”). Work qube, personal qube, banking qube, untrusted-browsing qube; they share only a clipboard and controlled file transfer.

Built on: Xen hypervisor, with Fedora dom0 (the privileged admin layer) and Fedora/Debian/Whonix templates (the VMs your apps actually run in).

Licence: FOSS.

Good for: high-sensitivity threat models. Journalists, dissidents, security researchers, anyone whose threat model assumes one app will eventually be exploited. If your browser gets owned, it gets owned inside a disposable VM; the rest of your system is unaffected. Snowden is a public proponent.

Bad at: running on laptops. Needs real hardware, 16GB RAM minimum (32GB comfortable), a recent Intel or AMD CPU with virtualisation extensions, and a whitelisted hardware list (check qubes-hcl before buying). Battery life takes a hit. GPU passthrough and suspend/resume are historically fragile.

Trajectory: stable and well-maintained. Qubes OS 4.3 released early 2026; 4.2 remains supported.

Use if: your threat model is serious, you have the hardware, and you’re willing to invest in the mental model.

Whonix (and the VPN question)

Two Debian-based virtual machines you run on top of another OS: a Whonix-Gateway that forces all traffic through the Tor network, and a Whonix-Workstation that only gets internet via the Gateway. Standard deployment is VirtualBox or KVM on a Linux / Mac / Windows host. The most secure deployment is inside Qubes, as Qubes-Whonix.

Short version for newcomers: Tor is a free anonymity network that routes your traffic through three volunteer-run servers, each of which only knows one hop, so no single party sees both who you are and what you’re doing. Whonix is a pair of VMs that forces every application on your computer to go through Tor whether the app knows about Tor or not. If an application tries to connect directly to the internet, Whonix blocks it. This is the part commercial VPNs cannot do.

Built on: Debian + Tor + a hypervisor (Xen / KVM / VirtualBox).

Licence: FOSS.

Good for: IP-hiding and anonymity as a persistent state. Unlike Tor Browser alone, Whonix forces every application in the Workstation through Tor, email, chat, VoIP, SSH, crypto wallets, everything. The “fail-closed” design means that if an app tries to bypass, it gets blocked rather than leaking.

Bad at: being a single-install OS. Whonix is the two VMs; it needs somewhere to run. Most users run those VMs on top of a regular host OS (Windows, Mac, or Linux via VirtualBox or KVM), which means the host OS is the weak link, if Windows is compromised, your keystrokes are exposed before Whonix sees them. Qubes-Whonix fixes this by running the Whonix templates inside Qubes, whose dom0 is hardened and not used for normal work. A dedicated bare-metal Whonix-Host OS (based on Kicksecure) is in development but the project explicitly says it is not yet ready for users. Physical isolation (running Gateway and Workstation on two separate physical machines) is supported but now rare. Beyond the deployment question: Tor is slower than direct internet, and some services (Cloudflare-protected sites, streaming services, banking) block Tor exits.

Is Whonix actually safer?

It depends on what you’re comparing to and what you’re trying to protect.

Safer than regular Linux for anonymity, yes. On regular Linux, applications connect to the internet directly, can see your real IP address, and can be tricked or exploited into leaking it. Whonix’s fail-closed design blocks any connection that doesn’t go through Tor, so even an exploited application can’t leak your IP because the operating system won’t let it.

Safer than Tor Browser alone for anonymity, yes. Tor Browser only anonymises what happens inside the browser. Your email client, your chat app, your SSH client, your cryptocurrency wallet, and anything else that touches the network are still connecting directly. Whonix extends Tor to every app on the Workstation.

Not safer than Qubes OS for compartmentalisation. Different axis. Whonix isolates you from the network. Qubes isolates apps from each other. The best answer is both: run Whonix templates inside Qubes, which is supported and well-documented.

Not immune to host compromise. Whatever is acting as the host of your Whonix VMs (your Windows / Mac / Linux desktop, or Qubes dom0) is where attackers will go first. If that layer is owned, your Whonix session is owned, because the host sees your keystrokes before Whonix does. The specific exploit class that crosses the host boundary is VM escape: a vulnerability that lets code running inside a guest VM execute on the host. KVM, Xen, and VirtualBox all have CVE history here; escapes are rare and patched quickly but exist. Qubes-Whonix on Xen with a minimal dom0 raises the bar against this class meaningfully higher than Whonix-on-KVM on a general-purpose Linux desktop, which is why Qubes-Whonix is the recommended deployment when the threat model includes active exploitation.

Not immune to Tor’s own limitations. A sufficiently powerful adversary that can see both ends of the Tor network (for example, a nation-state that monitors both your country’s internet and the destination site) can correlate traffic and reduce anonymity even through Tor. This is a real limitation and is why Tor is not a substitute for operational security discipline.

So: Whonix is the best practical tool for IP-level anonymity and app-wide Tor routing on consumer hardware. It is not magic, it does not protect against a compromised host, and it does not replace thinking about what you’re actually doing.

Does using Whonix mean I don’t need a VPN?

Yes. Whonix routes all your Workstation traffic through Tor. Tor is the anonymity layer, and it is a much stronger one than any commercial VPN: Tor is a three-hop encrypted anonymisation network run by thousands of independent volunteers, whereas a VPN is a one-hop encrypted tunnel run by a single company that knows exactly who you are and could be compelled to say so. Stacking a commercial VPN on top of Tor does not compound privacy, one extra layer run by a single identifiable party does not strengthen three layers run by unrelated volunteers.

The Whonix project’s own documentation goes further and describes the VPN-plus-Tor debate as “the law of triviality / bikeshedding”, people argue about VPNs because VPNs are easy to argue about, while the real privacy issues (browser fingerprinting, traffic-analysis attacks, keystroke timing, guard-relay discovery) get less attention[^whonix-vpn]. Commercial VPNs also have their own problems: single-party trust, the possibility of logging, the Port Shadow attack disclosed in 2024 affecting shared-port VPN servers, and the fact that VPN companies are routine acquisition targets for surveillance operators.

You can add a VPN to Whonix if you have a specific reason: your ISP blocks Tor and you need to reach Tor through a VPN (user → VPN → Tor → internet), or you want Tor to reach a service that blocks Tor exits (user → Tor → VPN → internet). Both configurations are advanced, explicitly unsupported by the Whonix team, and not leak-tested the way the base setup is. Without one of those specific reasons, don’t do it.

The same logic applies to Tails. Tor is the protection; a VPN adds complexity without adding anonymity.

Short answer: use Whonix or Tails, don’t bother with a commercial VPN on top.

Trajectory: stable. Funded through 2026 by Power Up Privacy.

Use if: you want Tor-forced anonymity as a persistent workspace. Best inside Qubes; acceptable standalone.

Tails

The Amnesic Incognito Live System. A Debian-based live USB that runs entirely in RAM, routes all traffic through Tor, and forgets everything on shutdown. Optional encrypted Persistent Storage for things you want to survive reboots. Merged operations with the Tor Project in September 2024.

Short version for newcomers: You boot your computer from a USB stick, everything runs in memory, and when you shut down, nothing is left behind, not on the USB, not on the computer, not in the logs of your ISP (because Tor anonymised it). Good for one-off sensitive sessions, research on shared computers, any situation where the computer should remember nothing.

Built on: Debian + Tor + GNOME.

Licence: FOSS, with non-free firmware blobs included for hardware support (Tails prioritises booting on arbitrary hardware over strict libre purity).

Good for: specific sessions where nothing should persist. Whistleblowing, research on sensitive sources, using a shared or borrowed computer, any situation that benefits from the machine forgetting. MAC address spoofing, Tor routing, amnesia by default, vetted software selection.

Bad at: being a daily driver. Runs from USB, every session starts clean unless you set up Persistent Storage, no GPU-accelerated gaming, narrower hardware support than a full Debian install.

What Tails protects against and what it doesn’t

Tails routes all traffic through Tor at the network layer, so destination services see a Tor exit IP and not yours. That is the strong property. The weaker property (worth understanding before relying on Tails for high-stakes work) is that Tails runs every application on the same kernel that has the real network interface. The kernel knows your real IP (it is bound to the physical NIC); Tor enforcement is a set of netfilter rules on the same system the apps run on. An exploit that gets code execution at sufficient privilege can read the real IP directly and exfiltrate it out-of-band, bypassing Tor entirely.

This is not hypothetical. The FBI’s Playpen operation in February 2015 used a Firefox vulnerability in the Tor Browser bundle to install what the agency called a Network Investigative Technique on visitors’ machines; the NIT read the real IP and transmitted it directly to an FBI server in Alexandria, Virginia, outside the Tor network. Roughly 137 federal prosecutions followed.[^playpen-nit] Tails users hit by the same class of exploit would have been deanonymised the same way: the architecture is not different on this axis. Whonix is structurally different here, the Workstation VM has no route to the real network and no awareness of the real IP, so a compromised application inside it cannot leak what isn’t there. See the Whonix entry above.

What Tails does to reduce the practical risk:

  • AppArmor confinement on Tor Browser. A successful Firefox exploit lands inside an AppArmor-restricted process that cannot read most of the filesystem, cannot execute arbitrary binaries, and cannot do much beyond browsing. Reaching the privilege level needed to query network interfaces is a separate exploit on top.
  • Unprivileged user account. The Tails user runs without sudo by default and the OS is read-only on the USB stick during a session.
  • Amnesia bounds persistence. A successful compromise dies at reboot. There is no foothold for the attacker to maintain across sessions, which is the property a long-term surveillance operator usually wants.

These do not change the architectural fact that the kernel possesses your real IP. They raise the cost of an exploit that uses that fact and limit how long a successful one lasts. For most users at most threat levels, that is enough. For users whose adversary will burn a browser zero-day in a single session, it is not, and Qubes-Whonix is the answer.

Trajectory: stable, well-funded, well-audited. The Tor Project merger in 2024 strengthened both sides.

Use if: you need amnesic, Tor-forced, leave-no-trace sessions. Keep a Tails USB in a drawer even if you don’t use it often.

Kicksecure

The Whonix team’s hardened Debian. Same base as Whonix, same security work, but without Tor in the default path, so you get normal internet speeds and unblocked access to sites that refuse Tor exits.

Short version for newcomers: “Hardening” in operating-system security means changing the defaults so that if something goes wrong, the damage is smaller. Vanilla Linux boots with a lot of features enabled that most people never use (some kernel modules, some network protocols, some permission combinations) and any of those can become an attack surface if a bug is found. A hardened system switches those off by default and tightens the remaining ones. Kicksecure does this systematically on top of Debian.

Concretely, Kicksecure applies:

  • AppArmor confinement, a Linux kernel feature that restricts what specific applications can do, even if they’re exploited. An AppArmored Firefox cannot read your SSH keys even if an attacker controls the browser.
  • Kernel-parameter tightening via /etc/sysctl.d/ and boot parameters, disables kernel features that enable kernel-level exploits, restricts what processes can see about each other, turns off obscure network protocols most people don’t use.
  • Anti-fingerprinting measures, removes or randomises some of the data that websites and network observers use to identify your specific machine.
  • Tightened filesystem permissions on sensitive files and directories.
  • Reduced attack surface, fewer services running by default, fewer open network ports, smaller installed package set.
  • Hardened memory allocator options that make certain classes of memory-corruption exploit harder.
  • A hardened web browser (Tor Browser or a hardened Firefox variant) available by default.

Built on: Debian. Built by the same team that builds Whonix. In fact, Whonix itself is built on top of Kicksecure, the Whonix VMs inherit every Kicksecure hardening measure, then add Tor-forced networking on top. Kicksecure is what you get if you take Whonix and pull the Tor layer off.

Licence: FOSS, with the same non-free firmware repository structure as Debian.

Good for: someone who wants hardened Debian for general use without Tor in the default path. You get Debian’s package ecosystem, Debian’s stability, and a security posture significantly better than a default install, but you still use the regular internet at regular speed. If you decide later that you want Tor-forced anonymity, you can add the Whonix-Gateway on top; your Kicksecure install becomes the Workstation.

Bad at: discoverability. The project is the less-famous half of the Whonix team’s work and most tutorials don’t mention it.

Trajectory: stable. Tracks Debian stable. Active development through 2026.

Use if: you want a Debian desktop with security defaults turned up, and Tor-routed anonymity is not your primary goal. This is also the right choice if you’re curious about Whonix but not ready for the full Tor-forced setup, you can graduate to Whonix later without reinstalling.

Vendefoul Wolf

A small, principled, Devuan-based distribution. Ships with OpenRC instead of systemd, XLibre (a community fork of X.org) instead of Wayland, no telemetry by default, and no AI integration. Preinstalls LibreWolf browser and KeePassXC password manager. Primary development community is Spanish-speaking but the distribution is fully usable in English. Uses the Calamares installer.

Built on: Devuan 6 with a custom kernel.

Licence: FOSS.

Good for: users whose stated values match the project’s stated values, no systemd, no Wayland, no telemetry, no AI. Ready-to-use for common hardware (printers, Bluetooth) out of the box. Fast install via Calamares. Runs lean on modest hardware.

Bad at: ecosystem and bus factor. Small team, repositories largely depend on Devuan upstream, small community. If the lead maintainer loses interest, the project is in trouble. Documentation is Spanish-primary, English-secondary.

Trajectory: active, niche, regularly releasing ISOs through 2026.

Use if: your priorities match the project’s stated defaults and you’re OK with a small project. Otherwise, Devuan with manual customisation gets you most of the way and has a larger base.

Trisquel and PureOS (libre)

Two of the Free Software Foundation’s endorsed fully-libre distributions. Zero proprietary firmware, zero proprietary drivers, zero non-free software in the default repos. Trisquel is Ubuntu-derived; PureOS is Debian-derived (shipped on Purism’s Librem hardware).

Good for: anyone who wants to use only free software. The FSF endorsement means every package has been audited for freedom; no closed-source binary blobs anywhere.

Licence: strictly FOSS, no proprietary firmware or drivers of any kind. Hardware compatibility will be narrow as a direct consequence.

Bad at: modern hardware. Most Wi-Fi cards, almost all GPUs, many laptops, and most printers need proprietary firmware these distros refuse to ship. You need specific hardware to have a working system.

Flags: both distros live inside the FSF’s political orbit, which has had its own governance tensions since 2019.

Trajectory: narrow-purpose, stable. Trisquel 12 released 2024; PureOS tracks Debian stable.

Use if: software freedom in the FSF-maximalist sense matters to you.

Where Linux comes from: the Unix lineage

Before the BSDs make sense on this list, you need the briefest history of where any of this came from. This is the one piece of context the rest of the doc depends on.

In 1969, a small team at Bell Labs in New Jersey (Ken Thompson, Dennis Ritchie, and others) built an operating system they called Unix. It was small, written in a language they also invented (C), and it established most of the ideas that modern operating systems still use: everything is a file, programs should do one thing well and compose together, text is the universal interface.

Unix was licensed by AT&T to universities and companies. One of those universities was Berkeley, where researchers modified Unix heavily and released their own version called BSD (Berkeley Software Distribution) starting in the late 1970s. BSD eventually replaced the original AT&T code, fought a legal battle with AT&T in the early 1990s, won, and became 386BSD, the ancestor of every BSD you’ll see today. The main surviving branches are FreeBSD, NetBSD, and OpenBSD. Apple’s macOS kernel also descends partly from BSD, which is why macOS feels Unix-like underneath the candy coating.

Linux took a different path. In 1991, a Finnish student named Linus Torvalds wrote a new Unix-like kernel from scratch, no Unix code, no BSD code, just the ideas and the interface conventions. He started because Unix was expensive and Minix (a teaching OS) was limited. His kernel is what we call Linux today, and it is a clean-room reimplementation of Unix ideas, not a descendant of Unix code.

So when you see OpenBSD or FreeBSD on this list, understand:

  • They are not “a kind of Linux.” They are Linux’s cousins from the same family of ideas.
  • OpenBSD and FreeBSD contain real lineage from AT&T Unix via Berkeley. Linux does not.
  • From a user’s perspective they behave almost identically to Linux. Same shell, same file layout, same basic tools. Differences mostly matter to system administrators and kernel developers.
  • BSDs tend to be more coherent by design, the kernel and the userland (the tools that run on top of the kernel, shell, file utilities, compiler) are developed together as one project, instead of the Linux model where the kernel and the GNU userland are separate projects stitched together.
  • BSD licensing is permissive (do whatever you want with the code, including ship a proprietary product built on it). Linux licensing is copyleft (derivatives must stay open). This has real consequences: Apple’s macOS kernel is partly BSD-derived; no major commercial consumer OS is Linux-derived in the same way.

One footnote on the lineage that belongs in any honest telling of it: Minix, the teaching OS Andrew Tanenbaum wrote in the 1980s (the OS Linus Torvalds learned Unix ideas from before writing Linux) is still alive, but in a place you can’t see. Intel ships a small Minix 3 instance embedded inside every modern Intel CPU as part of the Management Engine firmware, running underneath your operating system with access to parts of the hardware you do not have. Tanenbaum himself did not know Intel had done this until it was reported publicly. If you are running a modern Intel CPU right now, a Minix instance is running on it that you cannot inspect, disable, or audit. This is not an argument for or against any particular distro; it is a reminder that the software that runs on your computer is not only the software you chose.

This matters for the decision ahead. If you want something that behaves like Unix because it literally descends from Unix, smaller and more conservative and audited more carefully than any Linux distribution, keep reading.

BSDs as an adjacent path

The BSDs share several properties that distinguish them from Linux: the kernel and base userland are developed as one coherent system, the codebases are smaller and more auditable, licensing is permissive (BSD / ISC) rather than copyleft, and governance is more centralised and engineering-led. They’re not on the “sovereignty right” end of the politics axis because they’re a different axis entirely, technical conservatism over political alignment.

OpenBSD

Theo de Raadt’s project, forked from NetBSD in 1995. Security and correctness through code auditing, minimalism, and aggressive default hardening. The smallest audited codebase of any general-purpose OS.

Licence: strictly BSD/ISC. OpenBSD goes further than most BSDs in refusing to ship code it can’t audit.

Good for: security-critical servers, firewalls (pf is one of the best packet filters in existence), anyone who values a system designed to be correct by construction. Default install is minimal. OpenSSH, LibreSSL, and pf are OpenBSD projects that the rest of the world uses.

Bad at: desktop polish, gaming, modern hardware (NVIDIA in particular is not supported), running recent proprietary software.

Use if: you want security through minimalism and you’re OK giving up modern desktop conveniences.

FreeBSD

The largest of the BSDs. Production-serious Unix with a vast ports tree and strong enterprise use. Netflix’s CDN runs on FreeBSD; PlayStation’s OS is FreeBSD-derived; TrueNAS (the most-deployed storage software in the world) is FreeBSD.

Licence: BSD.

Good for: servers (especially storage), NAS, coherent Unix desktops (see GhostBSD below for a ready-to-run desktop build). ZFS (a modern filesystem with snapshots, compression, and data-integrity checking) is first-class. Jails are lightweight containers that predate Docker by a decade.

Bad at: Linux-specific software assumptions. Gaming works via Wine + Steam + a compatibility layer but lags Linux meaningfully.

Trajectory: 15.1-RELEASE shipped 16 June 2026, the second point release of stable/15. Supported through 31 March 2027; the 15 series runs through 31 December 2029. 15.0-RELEASE reaches EOL on 30 September 2026, so a fresh install today should target 15.1.

Use if: you want a coherent Unix for a server, a NAS, or a serious desktop, and you don’t need Linux-specific software.

GhostBSD

FreeBSD made desktop-usable. Started by Eric Turgeon in 2009 to make FreeBSD installable and productive for a regular user without reading the FreeBSD Handbook first. MATE is the default desktop, with XFCE and a new Gershwin (GNUstep-based, macOS-like) community edition. The graphical installer handles partitioning and uses ZFS by default. Versioning scheme is YY.MM-R<FreeBSD base>p<patch>, for example 26.1-R15.0p2.

Short version for newcomers: Not a first-Linux replacement, because it isn’t Linux. For someone who already uses Linux comfortably and is curious what BSD feels like, GhostBSD is by far the easiest way in.

Built on: FreeBSD. Uses FreeBSD’s packaging (pkg), ports tree, and rc init (FreeBSD does not use systemd and never has).

Licence: BSD.

Good for: someone who wants FreeBSD’s security posture, ZFS-by-default, and jail ecosystem with a working desktop on first boot. No systemd, ever. Excellent ZFS integration. WireGuard and 802.1X enterprise WiFi in the network manager as of the 2026 release. Small project with engineering-led governance, the lead developer switched the default display server from X.Org to XLibre in April 2026 specifically because of the governance turbulence around X.Org’s reverts, which is the clearest statement of priorities a distro can make.

Bad at: gaming. Proton runs through FreeBSD’s Linuxulator compatibility layer rather than natively, with meaningfully worse coverage and performance than on any Linux distro. Steam is not officially supported on FreeBSD. Bad at: the long tail of Linux-assuming desktop software, one recent reviewer confirmed Obsidian and Joplin won’t install; Adobe, Spotify-official, and other proprietary Linux-targeted apps typically don’t exist for FreeBSD. Bad at: fractional display scaling (not supported). Bad at: Wayland (X.Org and XLibre only; Wayland is on the roadmap but not shipped). The installer only supports FreeBSD’s BIOS loader, no rEFInd, no FreeBSD boot manager option during install. In-place upgrades from 25.02 to 26.1 stay on X.Org; fresh installs boot to XLibre.

Trajectory: 26.1-R15.0p2 shipped 18 April 2026, the first GhostBSD release on FreeBSD 15.0. Default shell switched to zsh. Active small-team development, predictable release cadence aligned with FreeBSD upstream.

Use if: you are Linux-literate, curious about BSD, and want a working desktop on first boot. Not for gaming, not for first-time migrants, not for someone who needs Adobe software.

A note on the XLibre dimension: GhostBSD’s adoption of XLibre is an engineering decision and reads cleanly as one (Good for, above). XLibre as a project is messier, its public-facing rhetoric drew an ArchWiki page deletion in April 2026 that doesn’t reduce to the GNOME-side CoC asymmetry pattern. See “The XLibre / ArchWiki case (April 2026): a contrast” inside Community politics for the longer treatment.

NetBSD

The portability project. Runs on more architectures than anything else on this list, desktop PCs, ARM boards, embedded hardware, VAX minicomputers, toasters.

Licence: BSD.

Good for: unusual hardware, research, education, extreme portability as a design principle.

Use if: you have weird hardware or you like the idea of one OS running on everything.

Further reading: how to actually learn Linux

Once you’ve picked a distro and installed it, the natural next question is how to actually understand the system under your hands. The short answer: books build the mental model, websites help you apply it. Most people starting out go straight to websites and Stack Exchange, copying solutions without understanding them, they can operate Linux but don’t understand it. Books fix that. Websites are for applying and troubleshooting. The two serve different moments in the learning process.

If you only read one book, read this:

  • The Linux Command Line, William Shotts. Free online at linuxcommand.org. The best starting point for building real competence at the shell.

From there, in a loose order:

  • How Linux Works, Brian Ward. Explains what’s actually happening under the hood without requiring you to be a programmer. Good second book.
  • Unix and Linux System Administration Handbook, Nemeth, Snyder, Hein, Whaley. The professional standard. Heavy. Called “the bible” in sysadmin circles.
  • The Linux Programming Interface, Michael Kerrisk. For understanding how Linux works at the system-call level. Kerrisk maintains the Linux man pages, so this is authoritative.
  • The Unix Programming Environment, Kernighan and Pike. Written in 1984 and still worth reading. Teaches how Unix was meant to be used and thought about.
  • Advanced Programming in the Unix Environment, W. Richard Stevens. Stevens was the best technical writer in this space. Dense but authoritative.
  • Linux Kernel Development, Robert Love. The accessible introduction to kernel internals, for when you want to start understanding what the kernel actually does.
  • Understanding the Linux Kernel, Bovet and Cesati. Goes deeper than Love’s book.

Websites, roughly ordered by when in your journey they’re most useful:

  • bellard.org/jslinux. Fabrice Bellard’s in-browser PC emulator. Boots Alpine, Buildroot, Fedora RISC-V, or Windows 2000 in a tab. Useful for playing with a real shell from a machine where you can’t install or boot anything.
  • linuxjourney.com. The best answer for a true newcomer starting from zero. Not tied to any distro. Structured like a course: what is Linux, what is the kernel, how does the filesystem work, in plain language.
  • The Gentoo Handbook. Even if you never install Gentoo, reading through it teaches you how a Linux system is actually assembled. Partitioning, filesystems, kernel compilation, init systems. Educational.
  • The Arch Wiki. Your permanent reference. You’ll use it for the rest of your Linux life regardless of what distro you run.
  • The Linux Documentation Project (tldp.org). Dated in places, but guides like the Linux System Administrator’s Guide still hold up conceptually.
  • kernel.org documentation. For when you’re past intermediate and want to understand the kernel itself.
  • man pages. Built into every Linux system. Underused by beginners. Learning to read man pages well is a skill in its own right.

A reasonable order: linuxjourney → Shotts → Ward → Gentoo Handbook (read, don’t necessarily install) → Nemeth → then branch into whichever direction your work takes you (sysadmin, programming, kernel). The Arch Wiki becomes a constant companion from roughly step three onward.

No amount of reading replaces breaking things and fixing them yourself. Books give you the mental model. Websites help you apply it. But the final teacher is the terminal.

Security Landscape: The Layered Stack

The map. The security overview is the trailhead and the reading order. This is the terrain between it and the first rung: what you are actually defending, the layered stack that defends it, how to size your effort, and how far the climb goes. Read it after the overview and before the first rung (the OS picker). You do not need to act on any of it yet; it is orientation, not procedure.

The six layers

Security on a single Linux workstation is a stack of six layers, not a single decision. Each layer defends against a class of threats the others don’t.

  1. Foundation. Which OS. Devuan is the default this project recommends; Qubes, Tails, or Whonix for specific high-security scenarios.
  2. Confidentiality. Full-disk encryption plus per-file encryption for sensitive data. LUKS at install time; Borg for backups; age for one-off encryption needs.
  3. Hardening. Reducing the running system’s attack surface. CPU microcode, kernel sysctls, AppArmor, MAC randomisation, encrypted DNS, USBGuard, Thunderbolt/DMA authorisation, kernel lockdown.
  4. Input vetting. Scanning the documents and files you receive before opening them. ClamAV plus Didier Stevens’ pdfid suite plus YARA, with Firejail as the sandboxed-open layer. The code you install is a second input class this layer does not cover: a package that runs on install is a different vector from a document you open, and its defences live in Choosing supply-chain tools.
  5. Detection. Catching tampering after it happens. AIDE plus debsums plus auditd is the standard stack.
  6. Compartmentalisation. Isolating credentials and identities across machines, or workloads on one machine. Qubes covers the on-one-machine case; a bundle queue (described in the overview) covers the credential-isolation case.

These layers compose; none of them is a wall on its own. Each shifts the odds rather than guaranteeing anything: a hardened machine running a browser can still be exploited, the exploit just costs more, persists less easily, and is likelier to be caught. Depth is the point.

Plus cross-cutting concerns that aren’t a single layer: hardware tokens, Heads coreboot for firmware-level integrity, the Tor-versus-VPN-versus-mesh question, browser hardening, secure messaging, off-machine backups, and the operational-security habits that no software can replace, all covered in the cross-cutting section of the security overview.

Everyone should run the baseline: Linux, full-disk encryption, the hardening floor, document scanning, and integrity monitoring. It is low-friction once set and it helps no matter who is after you. From there, climb as far as your time and money allow: add anonymity (Tor, Tails) for sessions that shouldn’t trace back to you; add isolation (Qubes, identity separation) for workloads that can’t safely share a machine; combine both into the maximal posture (Qubes-Whonix on coreboot hardware) if you’re under active targeting and will keep it maintained. The detail is in “The ascent” below.

The 14 attack surfaces, ranked

First, the broader picture: what attack surfaces actually matter for a normal person’s online privacy. The main OS guide compresses this into one paragraph in “Why your OS matters.” The fuller version, in rough order of how much practical privacy loss each one accounts for:

  1. The browser. The single biggest surface. Fingerprinting via canvas, WebGL, fonts, screen resolution, timezone, plugin list. Cookies, supercookies, localStorage, IndexedDB, referrer headers, and the sheer volume of JavaScript execution that can exfiltrate data. Most people spend 90% of their online time here.
  2. DNS. Almost always leaks where you go even if everything else is encrypted. Your ISP sees every domain you resolve unless you’re using encrypted DNS, and even then, the resolver operator sees it all.
  3. Your ISP. Sees your IP traffic metadata regardless of content encryption. Knows your real identity by contract.
  4. The OS itself. Telemetry (Windows is catastrophic here), automatic connections to update servers, NTP pings, captive-portal detection. All happen before you do anything deliberately.
  5. Your IP address. Ties your physical location and ISP identity to everything you connect to. VPNs shift trust to the VPN operator rather than eliminating the problem.
  6. Account linkage. Logging into any account immediately collapses anonymity across sessions. Email addresses, phone numbers, and OAuth logins are identity anchors.
  7. Metadata on communications. Even with end-to-end encryption, who you talk to, when, how often, and message sizes are usually visible.
  8. Hardware identifiers. MAC addresses, hardware serials exposed through the OS or browser, CPU/GPU fingerprinting via timing attacks.
  9. Behavioural fingerprinting. Typing rhythm, mouse movement patterns, scroll behaviour, time-of-day usage patterns. Passive and hard to defeat.
  10. Third-party content. Trackers, ad networks, CDNs, embedded fonts, and analytics scripts loaded on pages you visit. A single Google Fonts call or Facebook pixel reports your presence to a third party.
  11. Email. Open-tracking pixels, IP leakage in headers from naive clients, and the fact that your counterparty’s provider sees everything.
  12. Mobile devices. GPS, cell-tower triangulation, accelerometer fingerprinting, app permissions, and the fact that iOS and Android are both hostile to privacy by design.
  13. Payment methods. Credit cards and PayPal create a permanent financial graph of your activity. Extremely hard to break without cash or privacy-preserving crypto.
  14. Physical layer. WiFi probe requests broadcast your device’s previously connected SSIDs. Bluetooth does similar things. Your router’s logs exist.

The browser and DNS together account for the majority of practical privacy loss for most people. Everything else matters, but fixing those two has the highest return. The OS sits at position four, which is why the project’s main guide is about switching it. The six layers above are how you systematically address surfaces 1 through 14: foundation (OS at position 4 plus what the OS enables you to defend at positions 1, 2, 5, 10), confidentiality (data at rest, complements 1 and 11), hardening (closes attack surfaces the OS itself opens), input vetting (catches malicious content arriving via surfaces 10 and 11 before it hits the workstation), detection (catches when defences fail), and compartmentalisation (limits damage when one layer falls).

Sizing your effort

The cheapest security is the exposure you never create. You can’t lose a password you never set, leak an account you never opened, or have metadata correlated that you never emitted. Before any tool: shrink your footprint. Fewer accounts, fewer identifiers tied to each other, less posted, less linked, less said. That shrinks every surface on the list above at once and costs nothing but discipline. Do it first, and keep doing it; no tool below recovers privacy you gave away by oversharing.

Then size how much of the rest is worth doing, and in what order. The standard framework is four questions, asked in order.

What am I protecting? Be specific. “My data” is too vague. Concrete answers: my financial records and tax history; my private communications with my source; my cryptocurrency wallet’s seed phrase; my notes on a sensitive medical condition; my draft of the article that will be published next month; the photos on my phone that no one else should see; the credentials that let me deploy code to production. List them. The list will reveal that you have several different protect-targets and they probably need different layers.

Who is plausibly trying to get it? Be honest. For most people: opportunistic malware authors; ad networks and data brokers building behavioural profiles; thieves who would resell a stolen laptop; a vengeful former associate; a corporate employer monitoring its devices; an ISP selling browsing data. For some, additionally: investigators on behalf of a hostile state, a corporate adversary in active litigation, organised criminals targeting cryptocurrency holders, intelligence services targeting journalists’ sources. Knowing who is actually after you doesn’t cap how far you climb (climb as far as you can afford) but it tells you which surfaces to close first.

How likely are they to succeed, and against what? A vague threat model produces vague defences. If your threat is “opportunistic malware,” the question is whether a browser exploit gets your home directory or just your browser sandbox; AppArmor and hardening fix that. If your threat is “stolen laptop,” the question is whether the disk is encrypted; LUKS fixes that. If your threat is “ISP-level surveillance,” the question is whether encrypted DNS and HTTPS-everywhere cover your traffic; you set those up once. If your threat is “I might be specifically targeted by a state-level actor with physical access to my hardware,” the question is whether Heads-coreboot detects the tampering; that’s a months-of-work answer with hardware purchases.

What happens if they succeed? The effort a defence is worth scales with the cost of failure. If the stakes are “I lose some embarrassing photos,” LUKS plus a reasonable browser is enough. If the stakes are “a source is identified and arrested,” Qubes-Whonix on Heads coreboot starts to look reasonable. If the stakes are “I lose my cryptocurrency cold-storage seed phrase,” the answer involves air-gapped machines and physical paper backups in safe deposit boxes.

Answer all four for each thing you’re protecting. The protect-targets usually vary, your photos need baseline protection; your source’s identity needs the anonymity branch; your cryptocurrency seed needs cold storage, so the answer is to use different layers for different things rather than apply one setting to everything. A common mistake is fixating on the protect-target that feels most dramatic (“I have a journalist friend, I should run Qubes”) while leaving the mundane surfaces that actually get most people (the unencrypted disk, the reused password, the un-vetted PDF) wide open. Close the boring surfaces first.

One more thing, because it cuts against the instinct to remove every annoyance: friction in your setup is often doing protective work you can’t see. The LUKS passphrase you type at every boot, the AppArmor profile that breaks one workflow per quarter, the hardware-token tap for sudo, the USBGuard block when a colleague hands you a stick, every one of those is a problem you could “solve,” and every such solution removes the friction that was the protection. Some problems are immune systems. Be deliberate about which ones you remove.

The ascent: do the baseline, then climb as far as you can afford

The old way to read this was “pick the user you are.” The better way: everyone does the baseline, then climbs. The goal is to be as safe as you can afford in time and money, not as safe as some adversary forces you to be. Aim high. The only real ceiling is the one in the guardrail at the end of this section: don’t build higher than you’ll keep running.

The baseline, everyone, regardless of who’s after you

Low-friction once it’s set, and it helps against every threat from opportunistic malware to a stolen laptop. Build it in this order; each step assumes the last is done.

  1. Replace the OS. If you’re still on Windows or macOS, this is the first piece of security work and nothing else here matters until it’s done. Devuan (this project’s default) or Mint; the picker is Choosing the OS. The migration closes the entire vendor-surveillance surface in one move.
  2. Full-disk encryption. Read Choosing encryption tools for the tool overview, then follow Full disk encryption with LUKS2 + LVM on Devuan on a fresh install. FDE can’t be retrofitted painlessly, so do it at install time. Long passphrase, written down and stored separately, plus a LUKS header backup on a USB stick kept in a different room, plus off-machine encrypted backups via Borg. Without all three you don’t have working encryption at rest; you have a brick-when-the-disk-dies, a panic-when-you-forget, or a one-fire-from-total-loss setup.
  3. Harden the running system. Devuan secure workstation, the install plus runtime-hardening sections, done in order. Skip kernel lockdown unless you understand the trade-offs (it can break DKMS modules and hibernation). Most of this is apt install plus a few config files; hours, not days.
  4. Vet documents before opening them. Set up the stack in Choosing document-scanning tools. Scan every PDF, EPUB, or Office document from an un-vetted source, then open it in firejail --net=none even after a clean scan. Document-borne malware is the most common compromise path for a normal user, and this returns more protection per minute than almost anything else on this list.
  5. Monitor integrity. apt install aide debsums auditd rkhunter, capture a clean baseline with aideinit, then cron daily AIDE and weekly debsums/rkhunter. The hard part isn’t setup, it’s reading the output, decide how you’ll actually see the reports before you turn monitoring on.

Plus the cross-cutting items below, set up once: a hardware token for SSH and 2FA, encrypted DNS (covered in the hardening step), and a privacy-respecting browser (LibreWolf as default, Tor Browser for sensitive sessions). For most people the baseline is the realistic stopping point, and it is already far harder to attack than what almost anyone runs. A motivated weekend-and-a-half gets you here.

Going further, two branches, take either or both as you can afford

These are different axes, not later rungs of one ladder; you might want one and not the other.

Anonymity: for activity that shouldn’t trace back to you. Sessions that need to stay uncorrelated with your daily identity: a journalist’s source contact, a whistleblower’s disclosure prep, research into hostile groups, coordination before a public action. Tools: Tor Browser for browsing, a Tails USB for amnesic sessions (the live-OS scenario in Choosing the OS), Whonix for whole-system Tor, a separately-encrypted USB for files that travel with these sessions. The discipline matters more than the tools: never log into a personal account from the anonymous context, never reuse a username across the two, time-separate the patterns. The full landscape of transports is in Choosing networking tools; messaging that fits this branch is in Choosing communication tools.

Isolation: for workloads or identities that can’t safely share a machine. When one app being exploited is genuinely dangerous because of what the next app over holds: a developer who also handles sensitive customer data, a cryptocurrency operator, a lawyer with privileged files and a personal life on the same hardware. Qubes OS is the maximalist answer (every app in its own VM; the hardware floor is real, 16GB RAM, IOMMU-capable CPU, supported laptop; the compartmentalised-daily-driver scenario in Choosing the OS). Short of Qubes, AppArmor gives per-program confinement on hardened Devuan. For credentials specifically, keep the machine that writes separate from the machine that holds push/sign keys: Qubes’ split-GPG and split-SSH, or the bundle-queue pattern for the git-push case.

Mobile runs parallel to all of this and can be climbed any time after step 1: a Pixel running GrapheneOS. At higher postures it stops being optional: a hardened workstation paired with a stock phone that betrays you is a half-measure.

The maximal posture, for active targeting

Both branches combined, on hardware you’ve taken control of. This is the stack for someone whose threat model genuinely includes nation-state-class adversaries: journalists where journalism is prosecuted, activists under state-aligned surveillance, people whose community is in an aggressive state’s crosshairs. The shape: Qubes-Whonix on Heads-coreboot hardware (the hardware purchase is itself part of the security work, supply-chain interception is in the threat model; Choosing the hardware); LUKS with /boot on a USB you carry and Argon2id key derivation; full hardening of every qube template with kernel lockdown and USBGuard enforced; DNS routed over Tor via the Whonix gateway; documents quarantined through a no-network analysis qube before they touch anything; integrity monitoring with off-host log delivery; multiple persona qubes; an air-gapped offline qube for signing; a hardware token for every credential; GrapheneOS on the phone; and operational discipline that treats every network interaction as logged. Setting it up properly is weeks; maintaining it is ongoing. At this level the documents in this project are the entry point, not the destination, read PrivacyGuides.org, the EFF Surveillance Self-Defense guide, Tails’ operational-security docs, and the Whonix forum.

The guardrail

Climb only as high as you will actually keep running. Security you abandon is worse than security you never built: it costs you the setup effort and then pays you back in false confidence, the Qubes install you stopped maintaining, the AIDE reports nobody reads, feel like protection while protecting nothing. “Affordable” includes attention, not just money and a setup weekend. So aim high, but pick the highest posture you’ll sustain and run that, not the one that looks most impressive in a threat-model diagram.

Choosing Encryption Tools

  • For: Linux newcomers deciding how to encrypt disks, files and backups.
  • Before you start: Choosing the OS.
  • Reading time: about 30 minutes.
  • You end with: one tool per job (LUKS, Borg, age or GPG, Cryptomator, VeraCrypt) and the reasons for each.

A guide to encrypting your files, folders, and disks when you’ve left Windows and Mac for Linux. Written for beginners. Updated as things change.

TL;DR

If you’re still on Windows or macOS and reading this, the highest-leverage move you can make is leaving. Both platforms encrypt your disk by default and then quietly hand the keys to the vendor. BitLocker uploads your recovery key to your Microsoft account. Apple holds your iCloud encryption keys unless you’ve gone out of your way to turn on Advanced Data Protection. You are not the only one who can decrypt your data on those systems. This is upstream of every other encryption decision you’ll make.

Migrate to Linux. Choosing the OS covers picking a distro. Once you’re there, encryption gets simple:

  • Encrypt your laptop disk. Enable LUKS at install. Every Linux installer offers a checkbox. This solves “stolen laptop” once and forever.
  • Backups. The backup tool encrypts the archive itself; this site’s backup section uses Borg (see Whys of backup).
  • Cloud-synced folders. Cryptomator on top of whatever sync provider you tolerate, or move to a provider that does end-to-end encryption natively (Proton Drive, Tresorit, Mega).
  • Encrypting a single file to keep for yourself. GPG to your own key if you already keep one for pass and signing, since it adds no new tool and no new key to back up; age if you do not run GPG.
  • Sending an encrypted file to someone. age. The recipient can install it in one minute on any platform.
  • Encrypted vault on a USB stick that has to work on Linux, Mac, and Windows. VeraCrypt today; LUKSbox once it leaves pre-1.0; not BitLocker To Go (recovery keys go to Microsoft).
  • GUI drag-and-drop for one file. Picocrypt.
  • Plausible deniability under coercion. VeraCrypt hidden volumes, the only tool in this category, and the Windows side of it is at risk through 2026 (more below).

If you’ve never encrypted a file in your life and you don’t know where to start, do this in order: turn on LUKS the next time you reinstall, install Borg and back up your home directory once a week (see Getting started with Borg), and don’t touch the rest of this guide until those two are habits.

What this artifact is not: a how-to. There are no commands here. This is for picking the right tool for the right job and understanding the politics of each. Full disk encryption with LUKS2 + LVM on Devuan covers the actual install procedure for users who want to bypass distro installer wrappers. This guide assumes the concepts beneath these tools (what encryption, signing, hashing and keys are) rather than re-explaining them.

Why your encryption choices matter: the surface you’re defending

You don’t need a threat model that includes nation-states for any of this to matter. The default threat model is much more banal:

  • A laptop gets stolen out of a coffee shop or airport. Without encryption, the thief mounts the drive on another machine and has every file you’ve ever opened, every browser session, every cached credential. With encryption, they have a brick.
  • A used SSD gets resold. Modern flash retains data far past what rm deletes. Without encryption, the buyer recovers your tax returns. With encryption, they recover noise.
  • A cloud provider is compelled to hand over data, gets breached, or has an employee curious about you. Without client-side encryption, the provider can read everything. With it, they hand over ciphertext.
  • A backup drive lives in a friend’s apartment or a parent’s house for off-site storage. Without encryption, anyone in either household can read it. With encryption, the off-site arrangement actually works.

This is the stuff encryption-at-rest defends against. What it does not defend against:

  • Malware on your unlocked, logged-in machine. The data is decrypted in memory while you’re using it; encryption-at-rest does nothing here.
  • Keyloggers. If the password gets captured, the maths is irrelevant.
  • Coercion. A government with rubber hoses gets the password. (The narrow exception below: VeraCrypt hidden volumes.)
  • Backups of the unencrypted versions of files. If you ever decrypted a file and then synced your home directory to OneDrive in plaintext, that’s the version that exists.
  • Cold-boot attacks on a powered-on or suspended laptop. RAM holds keys for several seconds after power is cut, longer if cooled. “Locked screen” is not “encryption is engaged.” Real protection means powered off.
  • Evil-maid attacks on an unattended laptop. An attacker with a few minutes of physical access to a powered-off machine can install a malicious bootloader that captures the password on next boot. Secure Boot, TPM-measured boot, and Heads are partial countermeasures.
  • “Self-encrypting” SSDs. Most consumer drives marketed as SEDs were shown by Radboud University researchers in 2018 to have firmware-level flaws that make their hardware encryption effectively useless against a determined attacker. Encrypt at the OS layer regardless of what the drive claims.

Why proprietary disk encryption is hostile

Both BitLocker and FileVault encrypt your disk competently. That’s the easy part. The hostile part is what they do with the keys.

BitLocker, in its default Windows 11 configuration, uploads your recovery key to your Microsoft account during initial setup. The official Microsoft documentation calls this a feature: “If you forget your password, we can help you recover your data.” What it actually means is that your encryption key is in Microsoft’s possession, available to anyone who can compel Microsoft to hand it over (subpoena, National Security Letter, internal compromise) and exposed in any future Microsoft account breach. There is a way to encrypt without uploading the key (you have to create a local account, refuse online setup, and use manage-bde from the command line) but Microsoft makes this path increasingly difficult with each Windows update. The ordinary user enables BitLocker and a copy of their key sits on Microsoft’s servers.

Apple’s FileVault doesn’t upload the disk-encryption key by default. But your iCloud data (including iMessage backups, photos, notes, files in iCloud Drive, and Safari history) is by default encrypted with keys Apple holds. Apple can read it. Apple can hand it over. Apple has handed it over, repeatedly, to law enforcement requests. The opt-out is called Advanced Data Protection, was introduced in iOS 16.2 in late 2022, requires you to set up at least one recovery contact or recovery key, and is off by default. Most Mac users have never heard of it. Their iCloud is end-to-end encrypted in the marketing material and not end-to-end encrypted in fact.

These aren’t bugs. They’re product decisions. Microsoft and Apple sell convenience and recoverability; they price that against your sovereignty over your own data, and the price is your sovereignty. This is the same pattern as Recall (Windows 11’s screenshot-everything feature), notarisation (Apple deciding what software you can run on your own machine), and account-required setup. The OS vendor takes a seat at the table you didn’t offer them.

The Linux equivalent (LUKS) does not phone home. The key derivation runs locally; the master volume key never leaves your machine; there is no recovery service. If you forget your password, the data is gone. That’s the deal. It’s a worse experience for the careless user and a categorically better one for everyone else.

This is the upstream argument for migrating in the first place: the encryption layer on Windows and Mac is not on your side. The companion OS guide covers what to migrate to. This guide covers what to do once you’re there.

A note on phones

Modern Android (since 10) and modern iOS encrypt the user data partition by default, tied to the screen lock. This is competent, hardware-backed encryption and for typical threat models you don’t need to do more. The tools below are for desktops, laptops, and external storage. If you’ve migrated your computer to Linux but your phone is still iOS or pre-GrapheneOS Android, that’s a separate conversation; see GrapheneOS, CalyxOS, and the phone section in the OS guide.

Out of scope here: password managers (KeePassXC, Bitwarden), encrypted messaging (Signal, Matrix), and email encryption layered on top of plaintext mail.

Four families of tool

Encryption-at-rest tools cluster into four families. The right tool depends almost entirely on which family fits the job.

Whole-disk / full-disk. The whole partition or disk is encrypted. You unlock it once at boot with a password; everything on it is then transparently readable until shutdown. Examples: LUKS (Linux), VeraCrypt system encryption. Use case: laptop or external drive that you want to be a brick if stolen.

Container vault. A single file (often hundreds of MB or several GB) that mounts as a virtual drive when unlocked. Inside it looks like a folder; outside it looks like one opaque blob. Examples: VeraCrypt containers, Tomb, LUKSbox. Use case: a vault on top of an otherwise-unencrypted system, or a portable encrypted volume on a USB stick.

Per-file or per-folder. Each file is encrypted individually, and the encrypted output is a file you can move around, sync, or share. Some present as a transparent virtual folder where you drop plaintext and it gets encrypted on save. Examples: age, GnuPG, Picocrypt, Cryptomator, gocryptfs. Use case: cloud-syncable encryption, sharing a single encrypted file with someone, or per-directory encryption on Linux.

Encrypted backup archive. A backup-format-first tool where encryption is built into the format. The output is a deduplicated, compressed, encrypted archive that only the backup tool understands. These are backup tools first, with encryption as a property of the format rather than the point of the tool; the backup section of this site covers Borg, and this guide does not re-select them. Use case: serious backup of large amounts of data to local or remote storage.

A sub-mode that crosses these categories: filesystem-native encryption. ZFS native encryption and Linux’s fscrypt operate at the filesystem layer, neither block-level (whole-disk) nor application-level (per-file). They appear once in the tools list below but conceptually they sit alongside the four families rather than inside any one of them.

The tools

For each, a short description, who maintains it, what it fits, its political and ideological lineage, and the main tradeoff. Each entry closes with a “Use if:” line.

LUKS / dm-crypt

The Linux kernel’s native full-disk encryption. dm-crypt is the kernel module; LUKS is the standard on-disk format on top of it; cryptsetup is the userspace tool. Every mainstream Linux distribution offers it as the “Encrypt my disk?” checkbox in its installer.

Maintainers: Linux kernel team. Milan Broz is the most prominent userspace maintainer.

Fits: laptop disk encryption, encrypted partitions, encrypted external drives that will only ever be used on Linux.

LUKS comes in two on-disk formats. LUKS1 is the original; LUKS2 (default on most distributions since around 2019) supports Argon2id key derivation, multiple keyslots with stronger metadata, and re-encryption of an existing volume. New installs should use LUKS2. Stay on LUKS1 only if your bootloader doesn’t support LUKS2 yet, which is increasingly rare.

A related modern variant: systemd-homed, which encrypts each user’s home directory in its own LUKS image rather than encrypting the whole disk. This gives “home only” protection (your data is encrypted; system files aren’t) and lets the encryption follow the user across machines. Politically it’s part of the systemd ecosystem and inherits everything that comes with that; see The systemd problem.

For users who don’t want to type a password every boot, modern Linux supports TPM2-backed unlock via Clevis or systemd-cryptenroll. The TPM holds the key and releases it only if the boot chain hasn’t been tampered with. Convenient; not appropriate against an attacker who has physical access to the powered-off device.

For users who want to skip the distro installer’s checkbox and configure LUKS by hand, Full disk encryption with LUKS2 + LVM on Devuan is the worked procedure: GPT + LUKS2 + LVM + ext4 with keyfile-in-initramfs and GRUB cryptodisk.

Political leaning: Linux-kernel mainstream. The boring engineering option. Ships in every distribution, gets used at industrial scale, has no political enemies to make. Trusts the Linux kernel security model implicitly.

Tradeoff: Linux-only. An encrypted LUKS partition is not natively readable from Windows or macOS.

Use if: you have a Linux laptop, period. There is essentially no reason not to enable this.

VeraCrypt

The successor to TrueCrypt, the legendary tool that mysteriously shut itself down in 2014 with the cryptic message “WARNING: Using TrueCrypt is not secure.” VeraCrypt forked from TrueCrypt 7.1a, fixed the bugs the audit had found, and continued. It does whole-disk encryption, encrypted container files, and (uniquely) hidden volumes: a volume inside a volume, where you give one password to reveal the outer (decoy) contents and a different password to reveal the inner (real) contents. The point is plausible deniability under coercion: if forced to surrender a password, you give up the decoy.

Maintainer: Mounir Idrassi at IDRIX (France). Effectively a single-person project. Audits funded by OSTIF (Open Source Technology Improvement Fund).

Fits: cross-platform encrypted containers (works on Linux, Windows, macOS); Windows full-disk encryption; the rare cases where hidden-volume plausible deniability genuinely matters.

Political leaning: post-cypherpunk, Snowden-era state-resistant. TrueCrypt was the tool of choice for journalists, dissidents, and activists; VeraCrypt inherits that lineage. Hidden volumes only make sense as a feature if you take seriously the threat of compelled disclosure by a state actor.

Current state matters. Version 1.26.27 (September 2025) added Argon2id, modernising the password hashing. But in early 2026, Microsoft terminated Mounir Idrassi’s developer account without explanation. He has been unable to sign new Windows drivers or bootloaders since. Linux and macOS releases are unaffected; the most recent upload to SourceForge is from late April 2026. Existing Windows installs continue to work, but the certificate authority used for the VeraCrypt bootloader expires in late June 2026 and Microsoft is revoking it in July 2026, after which Secure Boot may refuse to load new VeraCrypt installations on Windows. Idrassi has called the situation a potential “death sentence” for VeraCrypt-on-Windows. This is not a cryptographic failure or a state-vs-encryption story; it’s a platform-vendor termination story, and the lesson is that even hardcore state-resistant tools depend on cooperation from the platforms they ship through. Note that this is also a strong argument for migrating off Windows, the same gatekeeping that’s killing VeraCrypt is what makes BitLocker the default.

Tradeoff: single-maintainer risk, now actively materialising on the Windows side. On Linux and macOS the tool is unaffected.

Use if: you need a cross-platform encrypted container right now, you’re on Linux or Mac, and LUKSbox isn’t mature enough yet. For Windows specifically, plan for migration off either Windows or VeraCrypt before mid-2026.

LUKSbox

A Rust-based encrypted-container tool from Sébastien Dudek at Penthertz (a French pentesting firm). Released as v0.1.0 on 7 May 2026, two days before this guide. Cross-platform: Linux and macOS via FUSE3, Windows via WinFsp. AES-256-GCM-SIV by default with Argon2id (256 MiB / 3 / 4) for password-based keyslots. FIDO2 (YubiKey, Titan, Nitrokey, Windows Hello) and TPM 2.0 keyslots for hardware-bound unlock. Hybrid post-quantum keyslots using ML-KEM-768 or ML-KEM-1024 with separate .kyber seed files. Detached header sidecar, the vault file alone is opaque random with no magic bytes, so plausible-deniability via detached-header is built in. Anchor sidecar for external rollback detection. Apache 2.0.

Maintainer: Sébastien Dudek, Penthertz.

Fits: cross-platform encrypted containers, especially when stored in untrusted cloud, on shared media, or on USB sticks that travel. Direct competitor to VeraCrypt with all of VeraCrypt’s known issues (single-maintainer-but-newer, no PQ until very recently, no hardware keys, C/C++ codebase, Microsoft-signing dependency) addressed by design choices.

Political leaning: hybrid lineage. The threat model is straight cypherpunk, the README’s “harvest now, decrypt later” framing for the post-quantum slot is canonical post-Snowden paranoia. The tooling lineage is modernist: Rust, Apache 2.0, hardware-key-first, post-quantum-first, FUZZing-and-audit-first development culture. Sits at the intersection of the two camps in a way no other tool on this list does. The fact that it ships from a French pentesting firm rather than a single maintainer or a German privacy company is worth noting, that’s a different funding base than any of the established tools.

Current state: pre-1.0. Nine internal audit rounds. 200+ tests passing, 30M+ fuzz iterations across 10 harnesses. No external audit yet (engagement scope package available on request). The on-disk format is locked; the cryptographic primitives are NIST/RFC standards built on RustCrypto.

Tradeoff: it’s brand new. The pre-1.0 status is real: for sensitive data today, VeraCrypt’s ten-year track record matters more than LUKSbox’s better design. Watch this project. If it gets an external audit and reaches 1.0 cleanly, it likely becomes the default cross-platform recommendation, especially given the VeraCrypt-on-Windows situation.

Use if: you’re paying attention to where the encrypted-container space is going. For production use today, default to VeraCrypt unless you specifically need FIDO2 or TPM keyslots and accept the pre-1.0 risk.

Cryptomator

Per-file encryption purpose-built for cloud sync. You create a “vault” (really a directory tree of individually encrypted files, with encrypted filenames) and point it at your Dropbox / Google Drive / OneDrive / iCloud folder. Cryptomator presents the vault as a virtual unencrypted drive locally; you work with it normally; the sync client uploads only the encrypted versions.

Maintainer: Skymatic GmbH (Germany). Cryptolib audits by Cure53.

Fits: storing sensitive files in commercial cloud storage without the provider being able to read them. Probably the best UX in the entire space for non-technical users.

Political leaning: Eurocentric privacy-commerce. The product assumes you’ve already adopted commercial cloud storage and want a layer of privacy on top, not that you’re going to self-host. Freemium business model, desktop free, full mobile functionality paid. Audited, professionally maintained, GDPR-compliance-shaped, sponsorship-funded. Calling this “least ideological” misses the politics: GDPR-Europe privacy-as-a-product is itself a stance, distinct from American surveillance-capitalism-with-encryption-as-an-afterthought and distinct again from cypherpunk self-hosting.

An alternative to the Cryptomator-over-Dropbox stack: pick a cloud provider that does end-to-end encryption natively. Tresorit (Swiss, proprietary, expensive), Proton Drive (Swiss, partially open-source clients, Proton ecosystem), and Mega (New Zealand, open-source clients) all encrypt client-side by design. You trade Cryptomator’s provider-independence for a more integrated experience and the politics of whichever provider you pick. For users still on iCloud or OneDrive, Cryptomator on top of those services is one of the few ways to make commercial-cloud use defensible.

Tradeoff: filenames and contents are encrypted, but file count, individual file sizes, and modification timestamps leak to the cloud provider. Mobile apps cost money. Single-company project, not a community.

Use if: you’re keeping a commercial-cloud sync provider and want them not to read your files.

age

A modern, minimalist file encryption tool, designed as an explicit reaction against GnuPG. The philosophy is in the README: “small explicit keys, no config options, UNIX-style composability.” Public keys are short strings starting with age1...; private keys are short strings starting with AGE-SECRET-KEY-1.... There are no key servers, no web of trust, no signing, no compression options, no hash algorithm choices. There is one cipher (ChaCha20-Poly1305 at the symmetric layer, X25519 at the asymmetric layer), and it works. age can also encrypt directly to an SSH public key, so anyone with an ssh-ed25519 key already has an age recipient, which makes distributing to a small team as simple as sharing the SSH keys they already publish.

Maintainers: Filippo Valsorda (independent maintainer, sponsorship-funded; previously crypto lead at Cloudflare and on the Go standard library) and Ben Cox.

Fits: encrypting a single file or directory to send to someone; encrypting CI/CD secrets (sops integrates with age); encrypting backups for archival; replacing every old “I use gpg -c for this” workflow.

Political leaning: post-PGP modernist. The age project is implicitly an indictment of GnuPG and the FSF-era cryptographic UX. Filippo’s writing makes this explicit: GPG is a “legacy” tool, age does “one thing brilliantly.” Stylistically and ideologically, age sits in the Go-ecosystem, Stripe-Cloudflare, modern-minimalist-crypto school. Funded by independent sponsorships from companies like Ava Labs, which gives it a more commercial-tech-adjacent profile than the FSF crowd would prefer. Reproducible builds attested via Sigsum, which is a real distinguisher from the older tools.

Current state: 1.3.0 (December 2025) added an X25519+ML-KEM-768 hybrid post-quantum recipient. Builds are reproducible and Sigsum-attested. A fully compatible Rust port, rage, reads and writes the same file and key format, for anyone who prefers a single static binary or the Rust toolchain.

Tradeoff: deliberately doesn’t sign. If you need authenticated provenance (“this file came from me”), you combine age with signify, minisign, or ssh-keygen -Y sign. There’s no built-in revocation story.

Use if: you want to send an encrypted file or directory to someone, or you want to encrypt one for yourself and do not already keep a GPG key. If you do keep a GPG key for pass and signing, encrypting your own at-rest files to that key consolidates onto one key you already protect; see the GnuPG entry. This is still the right default for “I want to encrypt this thing” when no GPG key is already in the picture.

GnuPG / GPG

The old guard. The canonical implementation of the OpenPGP standard. Encryption, signing, key management, web of trust, key servers, smartcard integration. It does everything; it does most of it badly by 2026 UX standards; and it remains essential because so many existing systems (apt signing, git commit signing, legacy email encryption, OS package distribution) depend on it.

Maintainer: Werner Koch and the GnuPG team. The funding situation has been precarious at multiple points, with bursts of corporate sponsorship after each near-collapse becomes news.

Fits: legacy interop, verifying package signatures, signing git commits, encrypting email under PGP/MIME, anything that already speaks OpenPGP. Also encrypting your own files at rest when you already keep a GPG key for pass and signing, where using that one key avoids adding a second encryption tool and a second key to back up. Rarely the right starting point for a new file-encryption workflow that has no GPG key behind it already.

Political leaning: FSF-adjacent cypherpunk old guard. GPL-licensed. Built around the web-of-trust philosophy of the 1990s, that key authenticity should be established peer-to-peer rather than via certificate authorities. The project’s institutional rhythm is famously slow, the UX is famously hostile, and the codebase is famously baroque. Modernisers (age, Sequoia-PGP) treat it as the cautionary tale.

Tradeoff: the worst available choice for someone with a free hand and no existing GPG key, since age is the cleaner file primitive; the sensible choice when you already hold a GPG key and want one key to cover signing, pass, and your own files at rest.

Use if: you need to interoperate with an existing OpenPGP system, or you already keep a GPG key for pass and signing and want to encrypt your own at-rest files to it rather than maintain a separate age identity. To send a file to someone who does not already use GPG, age.

Picocrypt

A very small, very simple, GUI-first file encryption tool. Drag a file in, set a password, get an encrypted blob out. Cross-platform, written in Go, distributed as a single binary that needs no install. Uses XChaCha20 with Argon2id key derivation. Optional Reed-Solomon error correction for long-term archival. A “paranoid pack” includes reproducible builds, source archives, and backup binaries.

Maintainer: Evan Su (single developer).

Fits: occasional encryption of one or several files via a GUI. The “I just want to put a password on this PDF and email it to my accountant” use case.

Political leaning: minimalist tooling with cypherpunk-flavoured populism. The cryptographic primitives are modern (XChaCha20, Argon2id) and the interface is austere in the age tradition, but the marketing copy invokes “three-letter agencies like the NSA”, that’s a cultural tell about who the project imagines its user is. Sits between age (austere, developer-focused) and VeraCrypt (heavy, cypherpunk-traditional). The “paranoid pack” with reproducible builds is a supply-chain-trust marker shared with age and LUKSbox.

Tradeoff: single-developer project. Limited CLI; the workflow really wants the GUI. Not designed for cloud sync, each operation produces a static encrypted file.

Use if: a non-technical relative needs to put a password on a file and you want them to succeed on the first try.

Tomb

A shell script wrapping LUKS to give you “tombs”, .tomb files that act like portable encrypted folders. Created by the dyne.org foundation, the same Italian hacker collective that has been making Free Software for two decades. The whole tool is a few thousand lines of Zsh.

Maintainer: Denis “Jaromil” Roio at Dyne.org. Active since 2007; recent FIDO2 security key support added.

Fits: an encrypted vault on a Linux box, with a workflow simpler than VeraCrypt and more discoverable than raw cryptsetup. Steganographic key hiding (in JPEG images) is supported.

Political leaning: explicitly hacker-collective and autonomist-leaning. Dyne.org’s other projects (Devuan-friendly tooling, dyne:bolic, free-culture audio software) sit in the European free-software-as-political-practice tradition. Of all the tools on this list, Tomb is the most explicitly ideological: the project’s documentation and its surrounding community make no secret of where they sit politically.

Tradeoff: Linux-only. Bash- and Zsh-based, which some find fragile. CLI-first; GUI wrappers exist but vary in quality.

Use if: you want a Linux-only LUKS-backed vault with a friendlier workflow than raw cryptsetup, and you’re sympathetic to the dyne.org political register.

gocryptfs

A FUSE-based encrypted overlay filesystem. You point it at a directory of encrypted files, give it a password, and it mounts a virtual directory where the files appear in plaintext. Per-file encryption with encrypted filenames. Designed as the modern successor to EncFS, which had known cryptographic weaknesses.

Maintainer: Jakob Unterwurzacher.

Fits: encrypted home directory or per-user encrypted folders on Linux/macOS; cloud sync where you want each file individually encrypted (similar use case to Cryptomator, but as a CLI/FUSE primitive rather than a polished app).

Political leaning: engineering-pragmatist; no ideology. The “do EncFS again, but correctly” project.

Tradeoff: Linux/macOS only (FUSE-dependent). Not a sync tool itself; you bring your own.

Use if: you want Cryptomator-style per-file encryption but as a CLI primitive you can script.

ZFS native encryption

Filesystem-level encryption built into OpenZFS. Each ZFS dataset can be independently encrypted with its own key, using AES-256-GCM or AES-256-CCM. Encrypted datasets can be sent and received between machines without the destination ever seeing plaintext (zfs send -w).

Maintainer: OpenZFS project, originally Sun (now Oracle for the legacy Solaris fork; OpenZFS is the community fork that runs on Linux, FreeBSD, and macOS).

Fits: serious storage setups, TrueNAS systems, ZFS-based backup destinations, large local pools where you want encryption granular to the dataset rather than the whole disk. Particularly strong for replicating encrypted backups across machines.

Political leaning: enterprise-storage-pragmatist. Originated at Sun, now driven by a mix of corporate (iXsystems, Lawrence Livermore) and community contributors. CDDL-licensed, which causes friction with the GPL Linux kernel and is itself a small political artifact. No threat-model ideology beyond getting filesystem semantics right.

Tradeoff: requires you to be on ZFS in the first place, which is its own significant decision. CDDL/GPL incompatibility means ZFS support on Linux ships out-of-tree and depends on DKMS or precompiled modules. Not appropriate for someone who just wants to encrypt a folder.

Use if: you’re already on ZFS or building a NAS / backup target and you want filesystem-native encryption that supports zfs send -w to untrusted destinations.

rclone crypt

Rclone’s encrypted backend. Rclone is “rsync for cloud storage” and supports something like 70+ providers; the crypt backend wraps any of them in transparent client-side encryption. Filename encryption is optional.

Maintainer: Nick Craig-Wood.

Fits: encrypted sync to almost any cloud storage backend you can name. Often paired with Restic or Borg for the backup-format layer.

Political leaning: pragmatist sysadmin. Trust nothing in particular, use anything you have to.

Tradeoff: rclone itself has a learning curve. The crypt layer is straightforward once rclone is set up.

Use if: your backup or sync destination is a cloud provider rclone speaks and you want client-side encryption layered on top.

Mentions and exclusions

EncFS is deprecated due to known cryptographic weaknesses. Use gocryptfs.

CryFS and securefs are alternative FUSE-based encrypted filesystems. CryFS chunks everything into fixed-size blocks to hide file sizes; securefs is smaller and less actively maintained. Either is usable; for most people gocryptfs has more momentum.

7-Zip with AES is encryption tacked on top of an archiver and has had crypto bugs in past versions. Functional for casual use, not crypto-first design.

eCryptfs (per-directory encryption that Ubuntu used to ship for ~/.private) is essentially abandoned. fscrypt (kernel-native per-directory encryption in ext4 / f2fs / UBIFS) is the current Linux successor and is fine if you want native kernel support without FUSE. Like ZFS encryption, it’s filesystem-native rather than fitting cleanly into the four families above.

Sequoia-PGP is a Rust rewrite of OpenPGP, German-funded, the most credible modernisation attempt for the GnuPG ecosystem. Worth knowing about if you’re stuck with OpenPGP for legacy reasons but want a less hostile codebase. Not a starting point for someone who has a free choice, pick age instead.

Kryptor is a single-developer age-like project with solid cryptography but a much smaller user base. Mentioned for completeness; not recommended over age.

DiskCryptor was a Windows-only TrueCrypt-era full-disk encryption tool. Abandoned; ignore older recommendations for it.

BitLocker (Windows) and FileVault (macOS) are excluded from the comparison because they’re proprietary and OS-vendor-controlled. See the “Why proprietary disk encryption is hostile” section above for what’s wrong with them. Section 6 covers what to do with existing BitLocker / FileVault volumes when migrating to Linux.

Decision matrix

Pick the row that describes your use case. The recommended tool is in column two; alternatives are in column three.

Use casePickReasonable alternative
Laptop is stolen, want disk unreadableLUKS during installVeraCrypt system encryption (cross-platform, with caveats)
External drive that’s Linux-onlyLUKS,
External drive that needs to be readable on Windows/Mac tooVeraCrypt containerLUKSbox (once 1.0+; pre-1.0 today)
Encrypted “vault” on existing Linux systemTombVeraCrypt container, LUKSbox
Encrypted home directory only (not full disk)systemd-homed or fscryptgocryptfs
Plausible deniability under coercionVeraCrypt hidden volumes (Linux/Mac viable; Windows uncertain through 2026)LUKSbox detached-header (when 1.0+)
Encrypted backup, local or SSH or cloudThe backup tool encrypts the archive; see Whys of backup,
Encrypted ZFS pool / dataset replicationZFS native encryption,
Cloud-synced encrypted folder (Dropbox / Google Drive / iCloud)Cryptomatorgocryptfs + sync client; rclone crypt; or move to a natively E2EE provider
Encrypted sync to any rclone backendrclone crypt,
Encrypt a single file to keep for yourself, at restGnuPG, to the key you already keep for pass and signingage, if you keep no GPG key
Send a single encrypted file to someone with no existing setupagePicocrypt (if they want a GUI)
Encrypt secrets or config inside a git repo, keeping it reviewablesops with the age backendplain age (whole-file, but the file is no longer diffable)
Send to someone who already uses GPGGnuPG,
GUI drag-and-drop for one or two filesPicocryptCryptomator (if vault model fits)
Per-directory encryption inside ext4 / f2fsfscryptgocryptfs
Verify package or git signatures (signing, not encryption)GnuPG,

The last row is for completeness: signing and encryption are different operations. If your goal is signing, GnuPG and ssh-keygen -Y sign are the practical options; the rest of this guide is about encryption.

Two pieces of practical advice not captured in the matrix.

First: full-disk encryption on the system you actually use is the highest-value action by a wide margin. Everything else here addresses narrower problems. A stolen unencrypted laptop is the typical real-world threat; LUKS solves it in one checkbox. If you’re still on Windows or Mac, the answer is the same as the answer everywhere else in this guide: migrate.

Second: don’t try to learn all of these. Pick one tool per job and stick with it. The most common failure mode in this space is people stacking three encryption layers, forgetting one of the passwords two years later, and losing the data permanently. Encryption is the most reliable way to lose your own data. Account for that in the choice.

Political leaning summary

Encryption-at-rest tools cluster into several ideological lineages. Knowing which one your tool belongs to predicts its threat model, its funding sustainability, and how it’s likely to behave under stress.

Linux-kernel mainstream. LUKS, fscrypt. Engineering-above-ideology, ships in every distribution, gets used at industrial scale, has no political enemies to make. The least flashy category and by far the most reliable.

Self-hosting sysadmin. rclone crypt, gocryptfs, ZFS native encryption. FOSS-pragmatist by temperament, but with a discernible preference for “your own server, your own disks” deployment patterns. Less ideological than the cypherpunks but more opinionated than pure infrastructure-as-utility. Stable funding because these tools are the backbone of competent self-hosting. Borg, which also sits in this lineage, has its own section on this site.

Cypherpunk / post-Snowden state-resistant. VeraCrypt, GnuPG. Built around the assumption that adversaries include nation-states. Hidden volumes, web of trust, paranoid threat models, sometimes-difficult UX as a feature rather than a bug. Currently the most fragile category, the VeraCrypt-Microsoft situation in early 2026 is a state-resistant tool being shut down not by a state but by a platform vendor, and the lesson is that even hardcore cypherpunk tools depend on cooperation from the platforms they ship through.

Modern minimalist / post-PGP. age, with Sequoia-PGP as a sibling on the OpenPGP-modernisation side. A reaction against GnuPG complexity by a younger generation of cryptographers. Smaller maintainer pools, smaller surface area, far better ergonomics, less institutional inertia. Funded by individual sponsorships and tech-company patronage rather than foundations. Reproducible builds and signed-binary attestation (Sigsum, transparency logs) are markers of this lineage.

Cypherpunk-flavoured populism. Picocrypt. Modern primitives, austere interface, but explicitly populist threat-model framing, the marketing names the NSA. Sits between modernist tooling and the older cypherpunk culture.

Modernist-cypherpunk hybrid. LUKSbox. Cypherpunk threat model (post-quantum, hardware-key, detached header for plausible deniability) implemented with modernist tooling (Rust, Apache 2.0, fuzzing-and-audit-first development, cross-platform via FUSE3 / WinFsp). The first tool on this list to combine those two camps deliberately. Funded by a French pentesting firm rather than a maintainer-of-one or a privacy-product company.

Eurocentric privacy-commerce. Cryptomator. Built for the user who already uses Dropbox and wants the provider not to read the files. Freemium business model, professional German company, audited, GDPR-shaped. The most commercially mature project on the list and the easiest to recommend to non-technical people. The politics aren’t absent; they’re “European data-protection law as a market position,” which is its own coherent stance.

Hacker-collective / autonomist. Tomb (Dyne.org). The most explicitly political category. European free-software-as-political-practice tradition. Bash-script aesthetics, KISS philosophy, leftist hacker culture.

The takeaway: the encryption itself is not the interesting variable. AES-256, ChaCha20-Poly1305, Argon2id, these are mature primitives and any of the actively maintained tools above gets the maths right. What differs is the threat model the project assumes, the funding base that sustains it, and the behaviours you’re being signed up for when you adopt their workflow. Pick on those.

Migration from Windows or Mac

The companion OS guide makes the case for leaving in detail. The encryption story is one piece of that case. Here’s how to handle the encryption layer during the migration itself.

If you’re on Windows with BitLocker. Decrypt the BitLocker drive before migration: Settings → Privacy & security → Device encryption → Off, or manage-bde -off from an admin command prompt. Wait for decryption to complete, back up the unencrypted contents to an external drive, install Linux fresh with LUKS enabled at install time, copy the data back. Don’t try to dual-boot a BitLocker-encrypted Windows partition alongside a LUKS-encrypted Linux partition with shared data, the boot complexity isn’t worth it.

If you can’t or won’t decrypt the original BitLocker drive (you’re keeping Windows as a fallback during transition, or the drive is read-only legacy), dislocker on Linux can mount BitLocker volumes read-only with the recovery key. Use it for one-time data extraction; not a long-term workflow.

For cross-platform portable drives going forward, use VeraCrypt today; LUKSbox once it reaches 1.0. Don’t use BitLocker To Go, Microsoft uploads recovery keys to your account.

For files that were in OneDrive: assume Microsoft has read them. If they’re sensitive, treat them as compromised and rotate any credentials they contained. Going forward, Cryptomator on top of OneDrive (or a switch to Proton Drive / Tresorit / self-hosted Nextcloud) closes the leak.

If you’re on macOS with FileVault. Same pattern: decrypt FileVault before migration (System Settings → Privacy & Security → FileVault → Turn Off), back up cleanly, install Linux fresh with LUKS.

apfs-fuse on Linux can read APFS volumes including FileVault-encrypted ones with the password, but it’s read-only in most configurations and not maintained by Apple. Use it for one-time data extraction.

For files that were in iCloud Drive: check whether you had Advanced Data Protection turned on. If yes, your data was end-to-end encrypted and Apple couldn’t read it, clean migration. If no (the default), Apple held the keys; assume any sensitive content was readable to Apple and treat it as compromised. Move to Proton Drive, Cryptomator over your sync provider of choice, or self-hosted storage going forward.

General advice for both directions. Decrypt and copy is almost always cleaner than trying to read foreign-OS encrypted volumes natively. Schedule the migration when you have time to sit through a full decrypt-backup-reinstall-restore cycle, ideally with two separate copies of the data on different physical drives. Encryption migration is the most common point at which people lose data permanently; budget extra paranoia for it.

Once you’re on Linux. Default starting setup: LUKS at install on the system disk, plus one secondary tool for whichever specific job is in front of you (Borg for backups, Cryptomator for cloud-synced folders, age for one-off files). Add others only when a new job genuinely requires them. Full disk encryption with LUKS2 + LVM on Devuan covers the install procedure in detail for users who want full control over the install.

How to think about choosing

Three questions, in order:

  1. What threat are you defending against? Laptop theft → whole-disk. Cloud-provider snooping → per-file or container. Compelled disclosure → hidden volumes (rare; high stakes). Long-term archive integrity → backup tool with authenticated encryption.
  2. Who else needs to read the data, and on what? Just you, on Linux → most options work. You and your future Mac or your Windows colleague → cross-platform (VeraCrypt, LUKSbox once 1.0+, Cryptomator, age, Picocrypt). A specific other person → age (modern) or GnuPG (legacy).
  3. How much UX friction can you absorb? Zero → LUKS (set it once at install, never see it again). Low → Cryptomator. Medium → VeraCrypt, LUKSbox, Picocrypt, Tomb. Higher but more powerful → age, Borg, Restic, rclone.

A fourth consideration that’s not really a question but matters more than any of the above: don’t lose your keys. Encryption is the most reliable way to permanently lose your own data. The typical failure looks like this: someone enables full-disk encryption, picks a strong password, doesn’t write it down (because writing down a password feels insecure), uses the laptop for two years, takes a long break, comes back to a locked screen and a blanked memory. There is no recovery from that.

Concrete countermeasures, in increasing order of paranoia:

  • Write the password down on paper and put the paper somewhere a thief wouldn’t look but you would (a sealed envelope in a filing cabinet, a safe-deposit box, a trusted family member’s house). Physical paper is not a meaningful threat surface for the attacks encryption defends against, and it’s the most reliable backup medium humans have for short secrets.
  • Use a password manager (KeePassXC, Bitwarden) and back up its database, but the master password itself has to live somewhere outside the password manager, so this just shifts the problem.
  • For LUKS specifically, save the LUKS header to a separate medium. If the header gets corrupted on the disk, the data is unrecoverable even with the correct password; the backup header restores recoverability. The command and the full procedure live in Devuan secure workstation, not here.
  • For backups, keep at least two physically separated copies of any encrypted archive plus the password to decrypt it. The backup is no good if the password died with the laptop.

Most people who go from “no encryption” to a single working LUKS-plus-Borg setup, with the password written down in a sealed envelope at home and the LUKS header backed up to a USB stick in a different room, get more real-world security than people who spend a year reading about hidden volumes and never finish setting anything up.

Choosing Communication Tools

  • For: people choosing a messenger or an email setup by threat model rather than by what their contacts use.
  • Before you start: nothing. Choosing networking tools covers the layer underneath.
  • Reading time: about 53 minutes end to end; the TL;DR gives the picks in two.
  • You end with: a default messenger, the upgrade path from it, and an email setup.

How to choose a messenger or email setup when the goal isn’t “what does my contact use” but “what threat model does the tool actually defend against.” Covers centrally-coordinated messengers, federated networks, decentralised cryptography, P2P and offline-capable messengers, Nostr-rooted messaging, radio-grade off-grid messaging, and (in a dedicated second half) the email landscape: encrypted-mailbox providers, privacy-respecting standard providers, self-hosted mail, encryption layers, clients, and aliasing.

This doc complements Choosing networking tools (the L3/L4 networking layer) by covering the application-layer messaging and email space. The two intersect at Briar and at Reticulum’s LXMF: both projects appear in both docs but with different framings.

TL;DR

The right default messenger depends entirely on your contacts. The right default messenger for sovereignty-minded readers who can influence their contacts is Signal for mass-market reach, with SimpleX as the sovereignty-aligned upgrade for the contacts who’ll move with you.

Beyond the default:

  1. Mass-market, US-jurisdiction-tolerant: Signal.
  2. Mass-market, US-jurisdiction-averse: SimpleX or Threema.
  3. Tor-only, metadata-resistant: Cwtch.
  4. Offline mesh capability: bitchat for Bluetooth-mesh on iOS and Android (Dorsey’s sovereignty-aligned project, deployed at scale in Uganda’s 2026 election), Briar for the Bluetooth-plus-Tor case on Android, Reticulum/LXMF for the radio-grade case.
  5. Self-sovereign keypair identity over Nostr with mature forward-secrecy: White Noise (MLS via the Marmot protocol).
  6. Self-sovereign keypair identity over Nostr with broader client ecosystem: Damus, Amethyst, 0xchat via NIP-17.
  7. Federation-preferring: Matrix via Element / Element X, or XMPP via Conversations.
  8. Voice and video focus: Jami.
  9. Email-bridge: DeltaChat for users who already have email and want PGP-grade encryption without learning a new app.

For email specifically (the second half of this doc), the ordering is by how much you can verify rather than how polished the product is. The open-source-and-auditable endpoint is self-hosting: Stalwart or mailcow on a clean-IP VPS is the only configuration where you control and can inspect the whole stack instead of trusting a provider’s server. Hosted providers are the convenience tier: Posteo or Mailbox.org for client-free standard IMAP/SMTP on your own domain, then Proton Mail or Tuta for encrypted-at-rest mailboxes (open clients, closed servers, single-jurisdiction court-compulsion exposure). Thunderbird or nmail for clients; SimpleLogin or addy.io for aliasing; own a custom domain early so changing providers never changes your address, which is what makes the climb toward self-hosting possible at all. Be wary of newer entrants: AtomicMail is too new and not fully open to recommend, and Skiff shows why, bought and shut down inside four years. The honest baseline, stated once and meant: email leaks metadata by design, so for genuinely sensitive communication use a messenger from the first half, not email.

Avoid: WhatsApp, Telegram (the default-unencrypted product, not the Secret Chats), iMessage cross-platform, Discord for anything you care about, Wickr (Amazon-owned, end-of-life uncertain). For email: Gmail, Outlook / Microsoft 365, Yahoo for anything sensitive.

The capture-risk frame for messengers

Three architectural questions structure the messenger landscape:

What identifies the user? Phone number (Signal, WhatsApp, Telegram), email (Wire, Threema for backup), username (Threema, Matrix, XMPP), random ID (Session, Briar, Cwtch), no identifier at all (SimpleX), or a cryptographic keypair generated locally (Nostr clients including White Noise, Reticulum).

Who routes the messages? A single company (Signal, Threema, WhatsApp), a federation of servers (Matrix, XMPP, DeltaChat over email), a peer-to-peer overlay (Briar, Cwtch, Berty, Jami), a content-addressed network of relays (Nostr, SimpleX), or volunteer-run radio infrastructure (Reticulum, Meshtastic).

What’s the company’s incentive? Subscription revenue (Threema, ProtonMail), donations and grants (Signal Foundation, Briar, Tor), VC-funded growth toward an exit (no current example in this list survives this filter), or commercial-and-consortium hybrid (SimpleX is building toward this with its 2026 Foundation/Consortium launch1).

The combination of those three answers is the messenger’s threat model. Signal answers “phone number + Signal Foundation servers + donations”, strong cryptography, real funding model, US-jurisdiction exposure. SimpleX answers “no identifier + your-choice-of-relays + commercial + foundation”, eliminates the metadata that the others handle imperfectly. White Noise answers “Nostr keypair + your-choice-of-relays + MLS encryption + Bitcoin-community-funded”, sovereignty by construction at the cost of UX maturity. Nostr DMs (NIP-17) answer the same as White Noise but with weaker forward-secrecy and no native multi-device.

Two more lenses run through both halves of this doc. The first is that open source is a floor, not a bonus: a tool whose source you cannot read is a tool whose behaviour you are taking on faith, so fully-open-source-and-auditable tools are prioritised here, partially-open tools (open client and closed server, or open stack and closed app) are named as exactly that, and closed tools are flagged. Open source is necessary but not sufficient, because for any hosted service you still cannot verify that the server runs the code it publishes, which is why the sovereign endpoint in both halves is something you run yourself rather than a provider you trust. The second lens is that longevity is itself a security property: a tool that has operated for a decade through funding scares and legal pressure has demonstrated resilience a six-month-old startup cannot, and the VC-funded-toward-an-exit pattern named above is the specific failure mode to fear. Skiff, in the Email section, is the worked example, a polished, partly-open, well-funded privacy startup acquired and shut down inside four years, stranding its users with no clean migration path. New is not disqualifying, and several of the strongest entries here are recent, but new is unproven, so unproven tools carry a maturity stamp here rather than a recommendation.

A note on MLS (Messaging Layer Security)

MLS is the IETF-standardised group messaging cryptography (RFC 9420, July 2023). It provides forward secrecy (compromise of current keys doesn’t expose past messages), post-compromise security (compromise self-heals as the ratchet advances), and end-to-end encrypted groups that scale to thousands of members without rekeying every pair separately. Apple, Google, Mozilla, Cisco, Wire, and Wickr were among the participants in standardisation.

Adoption in 2026: Wire was the early production deployment; Element X is migrating Matrix from Olm/Megolm to MLS; White Noise is the first Nostr-native MLS messenger via the Marmot protocol; the IETF MIMI working group is building cross-network MLS interop on top. MLS is the trajectory most serious-use messengers are converging toward.

The MIMI (More Instant Messaging Interoperability) IETF working group is the cross-network interop layer being built on top of MLS. Four drafts are active as of early 2026: draft-ietf-mimi-arch (the architecture), draft-ietf-mimi-protocol (the MIMI-over-HTTPS-and-MLS transport spec), draft-ietf-mimi-room-policy (room policies for groups and multimedia conferences), and draft-ietf-mimi-content (the message-content format)2. Authors include Richard Barnes (Cisco), Matthew Hodgson and Travis Ralston (Matrix.org Foundation), Konrad Kohbrok and Raphael Robert (Phoenix R&D), and Rohan Mahy. The Matrix-side participation is heavy; Element X’s MLS migration is the most visible implementation track. What MIMI lets you do at the spec level once it lands: a user on provider A and a user on provider B exchange E2E messages without either provider operating a bridge. The bridge becomes a protocol, not a piece of infrastructure. Trajectory for the next 18 to 24 months; not deployable yet.

Tier 1: Centrally-coordinated, mass-market

The “one company, professional ops, strong crypto” tier. The right answer when reach matters and the threat model tolerates trusting a single well-resourced operator.

Signal

US-based, Signal Foundation governance, Signal Technology Foundation legal entity. Founded by Moxie Marlinspike, current president Meredith Whittaker since 2022. End-to-end encryption via the Signal Protocol (Double Ratchet, X3DH, post-quantum extension via PQXDH since 2023), which is the cryptography most other secure messengers also use under the hood (WhatsApp, the Skype business of Microsoft, Google RCS). Open-source clients on every major platform; server source-available with some lag relative to deployment.

Phone-number identity is the recurring flag. Sign-up requires a phone number; the number is also your visible identifier to contacts by default. Username support shipped in 2024 to mask the phone number from new contacts but the number remains the account anchor. SIM-swap attacks are real against any phone-rooted identity system.

US-jurisdiction shape: Signal Foundation is a US 501(c)(3); the legal compulsion model is “the FBI asks, the Foundation provides what’s technically possible to provide,” which is by design very little (account creation date, last connection date, no message content, no contact list). The 2016 Eastern District of Virginia subpoena response is the canonical example: the Foundation provided account-creation timestamp and last-connection timestamp; that was all it had to provide.

Community-politics flag: Meredith Whittaker’s public political alignments are explicit and well-known (anti-corporate AI surveillance, AI Now Institute background); how much weight that carries varies by reader. The technical posture of Signal under her presidency has remained strong (post-quantum protocol upgrade, username feature, no telemetry-creep, no monetisation-creep).

Pick Signal as the default mass-market messenger if: your contacts will install it (the largest “your contacts will install it” surface area of any privacy messenger by an order of magnitude), and you understand the phone-number flag. The cryptography is genuinely strong; the structural risks are jurisdiction and phone-rooted identity, not the crypto.

Threema

Swiss, founded 2012, paid (one-time purchase, currently around €4.99). Identity is a Threema ID, eight characters, randomly generated, no phone number, no email required at registration. Optional verification of email or phone for friend-finding only; not required.

Open-source clients since 2020; the server stack remains closed. Audited multiple times; cryptography is solid (NaCl-based, custom protocol). Used by the Swiss government for internal communications, which is a credible operational endorsement.

Capture-risk shape: single Swiss company, paid subscriber base. Swiss jurisdiction provides EU-level data protections plus a cultural reluctance to assist foreign-government subpoenas. Smaller user base than Signal by far.

Pick Threema if you want the no-phone-number property, you’re willing to pay (the payment is the user-not-product signal you actually want here), and your contacts will install it. Common in DACH-region (Germany, Austria, Switzerland) friend and family networks.

Tier 2: Federated

The “no single company can shut you off; multiple operators can interoperate” tier. The trade-off is more configuration and a thinner UX layer.

Matrix (via Element, Element X, or alternatives)

Federated network of servers (homeservers) running the Matrix protocol. Element (formerly Riot) is the reference client, made by Element Software (formerly New Vector), the same company that maintains the spec. Element X is the rewrite shipping since 2024, Rust core, native iOS and Android, designed around MLS as the encryption layer rather than the legacy Olm/Megolm pair. Matrix.org is the largest single homeserver; tens of thousands more exist across self-hosted and community deployments.

End-to-end encryption: Olm/Megolm in legacy Element; MLS-based migration underway in Element X with the goal of cross-net Mimi interop. Element X ships MLS-enabled rooms by default for new conversations on supported homeservers as of 2026. The migration story for existing rooms is incremental.

Capture-risk shape: federation gives you the right to leave any homeserver and take your identity (via cross-signing keys and exported backups). The reality is messier: if you’ve been on matrix.org and that homeserver goes down, you can rejoin rooms from a new server but the operational hassle is real. Element Software the company has had funding turbulence (2023-2024); the protocol survives the company.

Pick Matrix if: you want federation as a structural property, you have contacts already using it (Linux communities, security communities, FOSS projects), and the UX rough edges don’t deter you. Self-hosting your own homeserver (Synapse, Dendrite, or Conduit) is the sovereignty-aligned configuration.

XMPP (via Conversations or other clients)

The older federation protocol; an open IETF standard. Conversations (Android) is the reference modern client; Gajim (desktop) and Snikket (turnkey self-hosted server) round out the practical stack. OMEMO is the end-to-end encryption layer, based on the Signal Protocol.

Smaller user base than Matrix; older codebase; fewer feature creep risks. The protocol itself has been stable for two decades.

Pick XMPP if: you want a protocol that has outlasted multiple companies and several federation experiments, you’re comfortable picking your own server (or running one), and you don’t need the rich-media UX Matrix invests in. Some sovereignty-minded operators specifically prefer XMPP over Matrix on the “older, simpler, more outlasted-companies” axis.

Tier 3: Decentralised cryptography

The “no central party knows the topology, even if the relay set is fixed” tier.

SimpleX Chat

The most architecturally interesting current entry. No user identifiers of any kind, not even random ones3. Conversations are routed via temporary pairwise queue identifiers; each conversation typically uses two different relay servers chosen by the participants. Your contact list isn’t on any server because there’s no server-side notion of “you.” Tor support built-in for IP-address protection.

Cryptography is end-to-end with quantum-resistant key agreement and the Double Ratchet for ratcheting. Trail of Bits cryptographic design review in 20244; reproducible builds; clients on iOS, Android, Windows, macOS, Linux (Flatpak, AppImage, .deb, console).

Current version v6.5 (April 30, 2026). The April 2026 release announced SimpleX Channels (publishing with participation privacy: channel content visible to relays but participants and authors anonymous to relays and each other) and the SimpleX Network Foundation and SimpleX Network Consortium structure, a Foundation that holds the protocol IP and a Consortium agreement between Foundation and SimpleX Chat Ltd that survives the company being sold or shut down1. This is the governance structure the sovereignty frame asks for.

Capture-risk shape: the architecture eliminates the user-identifier capture risk by construction. The relay operators still see traffic patterns (encrypted content, but timing and volume); the Tor option closes the IP-address leak. The Foundation/Consortium structure addresses the “what if the company is bought” question explicitly.

Pick SimpleX if: you can persuade your contacts to install it (smaller than Signal but growing), you want the strongest current architectural privacy guarantees in a usable mass-market-style app, and you’re aligned with the structural choices the Foundation is making.

Session

Loki/Oxen Network-based, no phone number, no email, no central server. Forked from Signal in 2019; uses the Session Protocol (a modified Signal Protocol that drops forward-secrecy in exchange for asynchronous delivery without a central server). Routes through the Oxen service node network using Lokinet onion routing.

Australian-headquartered (Session was originally a Loki Project initiative). The Oxen Network has had token-economy turbulence over the years.

Pick Session if: you want a fully decentralised routing model, you accept the forward-secrecy trade for the offline-delivery property, and the Oxen Network’s continued operation matches your time horizon. Smaller user base than SimpleX; less active development as of 2026.

Tier 4: P2P and offline-capable

The “no internet required” tier. Either pure peer-to-peer or with internet as one transport among several.

Briar

P2P messenger built around Tor for the internet case plus Bluetooth and WiFi-direct for the offline-mesh case. Messages can also be transferred via USB stick (the “censorship-resistant” use case the project explicitly designs for).

Android-first; desktop clients (Windows, macOS, Linux) exist; Linux mobile support in beta. No accounts, no phone numbers, no email; identity is a cryptographic keypair generated on the device.

The unique property: works without any internet connectivity at all. Two Briar users in the same building can communicate over Bluetooth indefinitely. Two users in adjacent buildings can communicate over WiFi-direct. Two users in the same city with no internet can relay messages via a third user who’s seen them both.

Status flag: last stable release v1.5.9 (January 2024). Active development continues per the project’s GitLab but release velocity has slowed. The protocol and architecture remain sound; treat as production for the use case it solves but watch the project’s health.

Pick Briar if: your threat model includes “the internet might go away” (protest scenarios, infrastructure disruption, hostile-state censorship), or you specifically want offline-mesh capability in your standard messenger stack. Pair with Reticulum/LXMF (Tier 7) for the radio-grade extension of the same idea.

Cwtch

Tor-only messenger built by Open Privacy Research Society (Sarah Jamie Lewis and contributors). Every conversation runs over Tor v3 onion services; no central server, no Cwtch service to compromise. Metadata-resistant: the protocol is designed so even traffic analysis between peers can’t reveal who’s talking to whom.

Smaller user base than Briar; cleaner architecture in some respects (no Bluetooth/WiFi-direct complexity). Desktop and Android clients.

Pick Cwtch if: your threat model is “metadata is the threat” (journalists protecting sources, activists in hostile jurisdictions, anyone whose social graph is the sensitive data) and you accept that everyone in your conversation must also be on Cwtch.

Jami

P2P SIP-based messenger and voice/video conferencing tool by Savoir-faire Linux (Montréal), free software, GPL-3. The closest thing to “Signal for voice and video with no central server.” Identity is a cryptographic keypair (RingID); discovery via OpenDHT (a distributed hash table); audio and video routed peer-to-peer where possible, with optional TURN-relay fallback you can self-host.

Capture-risk shape: no central server at all. Savoir-faire Linux maintains the software and runs default DHT bootstrap nodes, but the company can be removed from the loop with self-hosted bootstrap. Available on Linux, Windows, macOS, Android, iOS, including Devuan native packages.

Pick Jami if: voice and video are first-class needs (not bolted onto a chat app), and you want serverless P2P architecture. Particularly useful for self-hosting in family or small-organisation contexts where the user count is low enough that DHT discovery latency isn’t an issue.

Berty

P2P over Bluetooth and Tor, similar conceptual space to Briar but with iOS support (which Briar lacks). Identity is a cryptographic keypair. Smaller project; less mature than Briar.

Pick Berty over Briar if: you need iOS support. Otherwise Briar is the more established choice in this space.

Keet

Peer-to-peer messenger built by Holepunch and backed by Tether and Bitfinex, the companies behind the USDT stablecoin and the Bitfinex exchange. Built on the Pear Runtime and the Hypercore protocol stack: identity is a cryptographic keypair, peers locate each other through a distributed hash table, and there are no servers in the middle at all. End-to-end encrypted text, voice, and video, plus unlimited-size file transfer (files move device to device with no server to cap them), with integrated Bitcoin Lightning and USDT payments. Launched in 2022, downloaded millions of times, with a download surge through early 20265.

Open-source status, stated precisely because it is the flag for this audience: the foundation is open source (the Pear Runtime, Hypercore, and Hyperswarm are published under permissive licences and are reusable by any developer for any P2P app), but the Keet client application itself is not open source. NixOS packages it as unfree, and a 2022 promise to open-source the app has gone substantially unfulfilled as of 2026. So Keet is an open-source P2P stack wrapped in a closed-source client, which is weaker than Signal (open clients, source-available server) and weaker than the fully-open P2P messengers in this tier: Briar, Cwtch, and Jami are open source end to end.

Capture-risk shape: zero servers by construction, so no operator to compel; the residual concerns are the closed client (you cannot audit what the app does with your keys) and, as with any DHT-based system, that the network maps your public key to your IP address to route traffic, so your IP is visible to the peers you connect with unless you add a network-layer cover such as a VPN or Tor. Funding flag: Tether and Bitfinex backing is what frees Holepunch from chasing subscription or ad revenue, and is also a commercial crypto-conglomerate dependency a sovereignty-minded reader should weigh on its own terms.

Pick Keet if: you want serverless P2P text with high-quality voice and video, no account and no phone number, you are aligned with the Bitcoin and stablecoin ecosystem it is built around, and the closed client is a trade you will accept for the architecture, the unlimited file transfer, and the payments. If end-to-end open source is the priority, Jami covers the same serverless-P2P-with-voice-and-video ground fully open.

bitchat

Decentralised messaging over Bluetooth Low Energy mesh, with internet-connected geohash channels via Nostr as an optional second layer. End-to-end encryption via the Noise Protocol Framework (XX pattern), no servers, no accounts, no phone numbers, no email. Built by Jack Dorsey (Twitter co-founder, Block CEO, and one of the most visible Bitcoin advocates in the technology industry) under his “and Other Stuff” open-source development collective, with significant community contribution since6. Repositories: github.com/permissionlesstech/bitchat (iOS, Swift, Unlicense / public domain) and github.com/permissionlesstech/bitchat-android (Android, Kotlin, GPL-3). iOS v1.5.x and Android v1.7.x current as of early 2026.

Architecture. Each device acts as both client and server in a BLE mesh; messages hop up to seven times to reach recipients outside direct range (~30m per hop). End-to-end encryption uses Noise XX, which provides mutual authentication and forward secrecy. Identity is a Noise static Curve25519 keypair plus an Ed25519 signing keypair, generated on first launch and stored in the device keychain. A user’s verifiable fingerprint is the SHA-256 hash of their Noise static public key, readable aloud or scanned via QR for out-of-band verification. Messages live only in device memory by default and self-delete unless explicitly saved. Channel-based group chats (IRC-style /join, /msg, /who) with optional password protection; store-and-forward to offline peers; emergency wipe via triple-tap.

Hybrid Bluetooth-plus-Nostr design. Geohash channels (added in 2025-2026 development) use an internet connection to bridge Bluetooth-mesh-local conversations with peers in the same geographic area who aren’t in BLE range. This puts bitchat at the boundary between Tier 4 (offline mesh) and Tier 5 (Nostr-rooted): primarily a Bluetooth-mesh messenger, with Nostr as the internet-connected complementary layer when one is available. The Tor transport for Nostr is shipped in the Android client via the self-compiled arti library (Rust Tor implementation), reducing APK size meaningfully versus earlier embedded Tor builds. Background persistence on Android lets a device serve as a mesh relay even when the user isn’t actively in the app.

Adoption signal worth naming. Ahead of Uganda’s January 2026 general election, opposition leader Bobi Wine urged citizens to use bitchat to bypass anticipated internet shutdowns. Ugandan search interest for “bitchat” surged in the run-up to the election per Google Trends. The use case bitchat was architected for (protest communication when telecom infrastructure is throttled or shut down) is the use case it’s actually being deployed for at scale in 2026. TestFlight beta hit its 10,000-slot cap within hours of Dorsey’s July 2025 announcement; iOS App Store reach (25.1k GitHub stars on the iOS repo as of early 2026) gives bitchat a distribution surface that Briar’s Android-first reach can’t match. This is the rare case where a sovereignty-aligned messenger reached the mainstream-app-store audience without compromising the architecture.

Sovereignty frame. bitchat occupies the same architectural slot in messaging that nostr-vpn (Choosing networking tools) occupies in networking: a Bitcoin-aligned developer applies the no-trusted-third-parties pattern to infrastructure that used to require centralised servers, ships open source, and lets the architecture speak. Dorsey’s funding via “and Other Stuff” rather than via VC or corporate roadmap puts the project structurally in the same class as Knots and as Malmi’s work. The two flags worth naming are the iOS-side closed-app-store distribution (same flag every iOS app carries; the source is open and Android builds from source are fully supported) and Block’s commercial position (Bitcoin financial-services company; Dorsey’s personal funding is what backs bitchat, not Block’s corporate strategy).

Capture-risk shape: zero by construction (no servers, no operator). Residual concerns are the iOS distribution channel and your trust in the binary build chain; both are addressable by building from source on Android.

Pick bitchat for: dense-Bluetooth scenarios where physical proximity is the use case (concerts, protests, dense urban areas, conference floors); jurisdictions where internet shutdowns are a real threat model (Uganda 2026 is the documented example; similar cases will recur); the Apple-ecosystem case where Briar’s Android-first reach doesn’t help; and any sovereignty-aligned stack where mainstream-app-store distribution matters for contact onboarding. Pair bitchat (Bluetooth mesh) with Briar (Bluetooth + WiFi-direct + Tor) and Reticulum + LXMF (LoRa mesh) for layered offline-capable messaging across physical-layer options.

Tier 5: Nostr-rooted

The newest application-layer messaging stack. Your identity is a secp256k1 keypair you generated; messages are signed and (for DMs) encrypted, then published to one or more Nostr relays that you choose. No accounts on any server; relays can be operated by anyone; you can run your own.

Two architectural sub-tiers inside Nostr-rooted messaging: the protocol-level DM specification (NIP-17, used by most current clients) and the MLS-based approach via the Marmot protocol (used by White Noise). The two have meaningfully different threat-model properties; pick the right one for your use case.

White Noise

The sovereignty-frontier Nostr messenger as of 20267. Built by Erskin Gardner (erskingardner) and Max Hillebrand (founder of Sound Money Solutions); aligned with the Bitcoin community. Built on the Marmot protocol8, which sits on top of three primitives: Nostr (identity and signalling), Blossom (Nostr file hosting standard for media), and MLS (RFC 9420 Messaging Layer Security for the cryptography).

What MLS gets you that NIP-17 doesn’t:

  • Forward secrecy. Compromise of current keys doesn’t expose past messages; the ratchet is per-message, not per-conversation-key.
  • Post-compromise security. The ratchet self-heals after compromise; future messages become inaccessible to an attacker who got the current key but doesn’t keep getting them.
  • Native multi-device. MLS treats each device as a leaf node in the group; you can join the same conversation from phone, laptop, and tablet without retransmitting messages to each pair-key separately. Each device holds its own keys; nothing about the multi-device support requires uploading a master key to any server.
  • Scaled group chat. MLS was designed for groups of thousands without rekeying every pair separately on each membership change. Native group support that NIP-17 lacks.

Sender-recipient unlinkability over relays: Marmot wraps the MLS messages in Nostr events in a way that hides the actual participants from relay operators, similar to NIP-17’s gift-wrapping but with MLS as the cryptographic floor instead of NIP-44.

Status: alpha. Mobile app via Apple TestFlight (Android paths under iteration); desktop builds available. Open-source alpha; the team explicitly requests audits and community feedback. Production-ready nowhere near; architecturally the most advanced Nostr messenger by some distance.

Pick White Noise if: you want forward secrecy and multi-device on Nostr-rooted identity, you can persuade your contacts to install an alpha-grade messenger, and you’re aligned with the Bitcoin community’s sovereignty frame. The architecture is what to learn from; deployment for daily use comes when the alpha stabilises.

Nostr DMs (NIP-17) and the client landscape

The protocol-level direct-message specification has gone through two NIPs (Nostr Implementation Possibilities):

  • NIP-04: the original DM spec. Encrypted but leaked metadata (sender and recipient pubkeys visible to relays, plus message timestamps and event IDs). Deprecated.
  • NIP-17: gift-wrapped DMs9. The current spec. Three layers: a sealed event (NIP-59) wraps the actual message (NIP-44 encrypted) inside an unrelated-looking gift-wrap event with random keys. Relays see only the gift-wrap; sender and recipient identities and the timestamp are hidden from relay operators.

Most modern Nostr clients support NIP-17. The trade-offs versus Signal-style messengers (and versus White Noise specifically): no forward secrecy (NIP-44 uses long-term keys, not ratcheting) and no native multi-device key sync. Multi-device requires either sharing your nsec (the secret half of your Nostr keypair) across devices, or using remote-signing protocols like NIP-46 / NIP-4910 to keep the key in one place.

Client landscape:

  • Damus (iOS, macOS): the most-polished iOS Nostr client. Built by William Casarin.
  • Amethyst (Android): the most-featureful Android Nostr client. Built by Vitor Pamplona.
  • Iris (web, Android): built by Martti Malmi (the same nostr-vpn author from Choosing networking tools). Web-first; reasonable mobile experience.
  • 0xchat (iOS, Android, desktop): privacy-focused Nostr client, NIP-17 DMs as default, recently added group-chat support.
  • Primal (iOS, Android, web): UX-polished cross-platform client; integrated Lightning wallet for zaps; sometimes feels closer to a Twitter-replacement than a messenger but the DM support is real.

Status

Ethereum-rooted decentralised messenger, peer of the Nostr-rooted projects in spirit but built on the Waku protocol (libp2p-based) and the Logos network rather than Nostr relays. Identity is a public-key derived address; no email or phone; integrated cryptocurrency wallet.

Status has been in development for years; the project’s positioning has shifted over time. Production-grade in 2026 but smaller user base than Nostr clients. Token-economic structure exists which is a flag for some sovereignty-minded operators (who prefer the Nostr-rooted projects’ relay-as-a-service model over token-incentivised infrastructure).

Pick Status if: you’re already Ethereum-aligned and prefer that protocol stack over Nostr; otherwise White Noise or NIP-17 clients are the more active sovereignty-frontier messengers.

Capture-risk shape (Nostr-rooted, all variants)

Zero by construction if the implementation is correct and you choose relays you trust. Your keypair is yours; relays you can swap out; messages are encrypted such that relays can’t read them. The remaining risk is the client itself (closed-source clients on closed-source app stores remain a closed-source-client-on-a-closed-source-app-store risk, the same as for any messenger).

What’s mature: protocol-level NIP-17 support across many clients; identity portability across clients via your single nsec; relay portability by changing your relay list. What’s young: White Noise as the production-grade MLS upgrade path; Marmot protocol standardisation; cross-client UX for forward-secrecy expectations.

Adjacent frontier: Pubky (Synonym)

Not a messenger today, and not Nostr-rooted. This note is parked at the end of Tier 5 as a sovereignty-frontier watch entry beside the Nostr-rooted stack, because Pubky’s stated comparison target is Nostr and a reader weighing Nostr will meet Pubky’s claims.

Pubky is Synonym’s open protocol for key-based identity and public data publishing.11 It replaces accounts with an Ed25519 keypair: your public key (the project calls it your pubky) doubles as a sovereign domain name.12 PKARR (Public Key Addressable Resource Records) publishes small signed DNS-style records under that key to BitTorrent’s Mainline DHT, a network of over ten million nodes, and PKDNS resolves those records like a DNS server.12 The record points at a homeserver: a conventional web server that stores your data per public key, grants apps write access only to the paths you approve, and serves everything under /pub/ publicly by default.11 Moving providers means republishing one PKARR record; the project calls this credible exit, and it is the architectural answer to the discovery contrast below.

The user-facing pieces: Pubky Ring (iOS and Android key manager, MIT, v1.15 June 2026) holds the keypair and authorises apps; pubky.app is the beta reference social app; Pubky Explorer browses what any key has published.12 Signup to the flagship homeserver is gated by invite code, SMS verification, or a Bitcoin Lightning payment as anti-spam measures.11 The SMS route links a phone number to the keypair, so anyone running an anonymous identity should take the invite or Lightning path, never SMS.

The Nostr contrast, which is the reason this note lives here. Nostr replicates your events across whichever relays you choose; portability is your nsec plus a relay list, and discovery means querying relays and hoping the right ones hold your data. Pubky keeps your data in one authoritative home and publishes a signed pointer to it on the DHT, so discovery is deterministic but availability hangs on a single homeserver until you migrate. Synonym CEO John Carvalho positions Pubky as “a strict upgrade” to Nostr on exactly this discovery axis; treat that as the vendor’s framing.13 What Nostr has that Pubky does not: a far larger client and relay ecosystem, NIP-17 encrypted DMs in daily use, and Lightning zaps. What Pubky has that Nostr does not: deterministic data location, and a DNS-replacement layer (PKDNS) that is useful beyond social.

Funding and capture-risk shape: Synonym Software was founded by Tether in November 2021, with Tether CTO Paolo Ardoino as Synonym’s CTO at launch, the same single-sponsor concentration the Keet entry above and the Holepunch/Pear note in Choosing networking tools carry; Pubky also succeeded Synonym’s earlier Slashtags project, which ran on the same Hypercore stack Keet runs on.14 On the other side of the ledger everything is MIT-licensed open source with a Rust core, and homeservers are self-hostable, including a community Umbrel package.12

Status: beta and public-data-only. There is no end-to-end encryption yet; the project’s own FAQ states Pubky is currently optimised for public data, with private and encrypted features planned under Pubky Noise, which exists as an early Rust repository.11 So today Pubky is an identity and public-publishing layer, not a messenger, and nothing sensitive belongs on a homeserver unless you encrypt it yourself first. The sovereignty-complete configuration is a self-hosted homeserver; using the flagship homeserver is trusting one operator, softened by the migration path. Revisit when Pubky Noise ships usable end-to-end encryption and signup ungates; until then this is a thing to try with throwaway data and to watch.

Tier 6: Email-as-transport

The “use the protocol every internet user already has” tier.

DeltaChat

A messenger UX over the email protocol. Sends and receives messages via standard IMAP/SMTP against any email provider. End-to-end encryption via Autocrypt (an OpenPGP profile designed for transparent in-band key exchange) by default with other DeltaChat users; falls back to ordinary unencrypted email with non-DeltaChat contacts.

The advantage: you can talk to anyone with an email address: they see an email; you see a chat. The disadvantage: the email infrastructure leaks the same metadata it always leaks (your provider sees who you talk to, when).

Pick DeltaChat if: you already have email, you want PGP-grade encryption without learning a separate app, and you have contacts who’ll install it. Particularly useful in mixed-tech-comfort family networks where “I’ll send you an email” works and “install this messenger” doesn’t. For the email infrastructure DeltaChat rides on (which provider, self-hosting, encryption layers) see the Email section below.

Tier 7: Radio-grade and off-grid

When the internet is unavailable and Bluetooth-range isn’t enough. Network-layer treatment of these tools lives in Choosing networking tools; this section covers the messaging layer on top.

LXMF on Reticulum

Reticulum’s messaging format. Reticulum is the network stack (covered in the other doc); LXMF is the application-level protocol that runs on top of it for store-and-forward messaging. Pair with Nomadnet (terminal-based) or Sideband (mobile, desktop) for the user-facing app.

Threat model: a fully sovereign messenger that works over LoRa radios, WiFi mesh, serial links, or Tor, any transport Reticulum supports. Cryptographic identity, end-to-end encryption, sender identity hidden from intermediate relays.

Pick LXMF if: you’ve decided to operate a Reticulum-capable mesh (RNode hardware, LoRa radios) and you want a messenger that runs natively on it. Also pick this for “messaging the bunker scenario” use cases where IP infrastructure can’t be assumed.

Meshtastic messaging

Meshtastic’s built-in text messaging, over LoRa. Channel-based (groups of devices share a channel key). Floods messages across the mesh; works at small community-mesh scale (hiking groups, neighbourhood resilience networks).

Pick Meshtastic messaging if: you’ve deployed Meshtastic hardware for the community-resilience reasons (cheaper than RNode, simpler protocol) and want messaging as one of the use cases. Not metadata-resistant in the way Reticulum is; not E2E-encrypted between specific pairs (channel-key based).

What to avoid

WhatsApp. Meta-owned, phone-number-rooted, metadata extensively logged and shared with Meta’s ad targeting graph. Cryptography (Signal Protocol) is fine; everything around the cryptography is the problem. The 2021 privacy-policy update explicitly authorised broader data sharing with Meta. Backups in iCloud/Google Drive are not E2E-encrypted by default.

Telegram for anything sensitive. Default chats are not end-to-end encrypted; only “Secret Chats” are, and they’re not the default mode. Group chats are never end-to-end encrypted regardless of mode. Pavel Durov’s 2024 arrest in France and Telegram’s subsequent policy adjustments on cooperation with law enforcement closed a chapter where Telegram could be framed as a privacy tool; it never was, and the marketing has caught up.

iMessage for cross-platform. Apple-only end-to-end encryption. When messaging to an Android user it falls back to SMS or RCS (RCS over Google’s network is also E2E now but the iMessage/RCS bridge had a turbulent rollout); the green-bubble cross-platform message often is not E2E. iCloud message backups are E2E only if Advanced Data Protection is explicitly enabled.

Discord for anything you care about. Not encrypted at rest from Discord’s perspective. Discord employees can read your messages. The terms of service authorise this.

Wickr. Acquired by Amazon in 2021; AWS Wickr is now the only continuing product; the consumer Wickr Me product was end-of-lifed. Don’t start new use on it.

Snapchat, Instagram DMs, X DMs (formerly Twitter). Not end-to-end encrypted by default; metadata extensively logged; the platforms’ business models depend on the data. X has experimented with E2E DMs in limited capacity but the rollout has been incomplete.

Where to start

Common scenarios.

“I want a default messenger to use with my phone contacts.” Signal. The crypto is strong, the user base is large, and persuading one contact at a time to install it is a tractable project. Accept the phone-number flag or use the username feature for new contacts.

“I want a sovereignty-aligned default with one or two contacts I can move with me.” SimpleX. Architecturally the strongest current mass-market-style messenger. The contacts who move with you will be the ones who care about the same things.

“I want a messenger that works when the internet is down.” bitchat for the Bluetooth-mesh case (iOS plus Android, App Store reach, deployed in Uganda 2026); Briar for the Bluetooth-plus-Tor-plus-WiFi-direct case (Android-first; slower release velocity); Reticulum with LXMF for the LoRa/radio case; Meshtastic for the community-LoRa case.

“I’m a journalist protecting sources.” Signal as the contact-acceptance default; SecureDrop for the actual source-onboarding flow; Cwtch for sources who are themselves metadata-sensitive. Read EFF’s SSD on this scenario specifically.

“I already use Nostr; I want my DMs to live there too, with forward secrecy and multi-device.” White Noise via the Marmot protocol. Accept that it’s alpha-grade; contribute back when you find rough edges.

“I already use Nostr and I’m fine with NIP-17 DMs for now.” Damus (iOS), Amethyst (Android), 0xchat (cross-platform), or Iris (web). Accept the no-forward-secrecy and no-native-multi-device trade-offs; plan to migrate to White Noise once it matures.

“I want federation as a structural property.” Matrix via Element X (the MLS-future-proofed client) or XMPP via Conversations. Self-host the server for the sovereignty-aligned configuration. Pair with a homeserver you operate (Synapse for Matrix, Snikket for XMPP).

“I want voice and video without a central server.” Jami. Self-hostable bootstrap nodes; no signup; works across desktop and mobile.

“I want PGP-grade encryption but my contacts will only use email.” DeltaChat. Both sides install it; both sides keep using their existing email; the encryption is automatic.

“I want every property at once.” You’re picking a Pareto frontier. The sovereignty-aligned stacked stack: White Noise as primary (when stable), SimpleX as the SMS/Signal-replacement secondary, Briar as offline backup, LXMF on Reticulum as the off-grid layer. None of these is Signal in terms of contact reach; the trade-off is real.

Maturity table

ToolTierOpen sourceStatusForward secrecyMulti-deviceCapture-risk shape
Signal1Yes (server lag)Production, matureYes (Double Ratchet)Yes (linked devices)US Foundation; phone-number identity
Threema1Partial (client only)Production, matureYesYesSwiss company; paid; ID-based
Matrix / Element2YesProductionYes (Olm/Megolm)YesFederation; Element Software has had turbulence
Matrix / Element X2YesMigrating to MLSYes (MLS)YesAs above; MLS migration in progress
XMPP / Conversations2YesProduction, matureYes (OMEMO)YesOpen standard; pick-your-server
SimpleX3YesProduction, v6.5; Foundation launchingYes (Double Ratchet, PQ-resistant)Yes (mobile profile)No user identifiers; commercial + foundation
Session3YesProductionNo (deliberate trade)LimitedOxen Network token economy
Briar4YesStable (slowed)YesNo (single-device)None; P2P
Cwtch4YesProductionYesLimitedNone; Tor-only
Jami4Yes (GPL-3)Production, matureYesYes (account portability)None; P2P over DHT
Berty4YesProduction, smallerYesYesNone; P2P
Keet4Stack only (client closed)ProductionNot documentedYes (device sync)None; serverless P2P over DHT; Tether/Bitfinex-backed
bitchat4YesProduction (iOS v1.5.x, Android v1.7.x)Yes (Noise XX)No (single-device)None; Bluetooth-mesh-plus-Nostr-geohash
White Noise5YesAlphaYes (MLS)Yes (MLS leaf nodes)None by design; Nostr-rooted
Nostr DMs (NIP-17)5YesProduction protocol; client UX variesNoNo native (nsec sharing or NIP-46)None by design
Damus / Amethyst / 0xchat / Iris5YesProductionNo (NIP-17 limit)No nativeNone
Status5YesProductionYesLimitedEthereum/Logos; token-economic
DeltaChat6YesProductionLimited (Autocrypt)Per-accountWhatever your email provider’s capture-risk is
LXMF on Reticulum7YesProductionYes (ephemeral keys)LimitedNone
Meshtastic messaging7YesProductionNo (channel keys)NoNone

Email

Everything above is messaging. This second half covers email, which is a different animal and deserves its own frame.

Why email is structurally weaker than the messengers above

Email is a 1970s federated protocol. The store-and-forward design means the metadata (who you email, who emails you, timestamps, and the subject line in transit) leaks by construction. SMTP between servers is opportunistically encrypted (TLS if both ends support it, plaintext fallback otherwise), and the message sits decrypted on the receiving server unless you’ve layered encryption on top. End-to-end encryption (PGP) is bolted on, not native, and even when used it protects the body, not the metadata.

The realistic goal for email is therefore narrower than for the messengers: provider trust plus encryption-at-rest plus optional PGP for body content. You do not get the metadata elimination that SimpleX or the gift-wrapped Nostr DMs achieve. The honest baseline, stated once and meant: for genuinely sensitive communication, use a messenger from the first half of this doc, not email. Email is for the half of your life that has to interoperate with everyone else’s email, which is most of it, which is why this section exists.

The capture-risk frame for email asks three questions, same shape as the messenger frame: who can read the body (the provider, unless E2E), who can see the metadata (always the provider and the network, PGP doesn’t fix this), and what’s the provider’s incentive (subscription, donation, or advertising-against-your-content).

Encrypted-mailbox providers

These encrypt your mailbox at rest with a key derived from your password, so the provider cannot read stored mail. Two catches apply to all of them. First, the encryption is end-to-end only between users of the same provider; mail to an outside address either goes out plaintext (subject to normal SMTP TLS) or uses a password-protected-link workaround. Second, and more important for the open-source lens this doc applies, their clients are open source but their servers are not, and even where source is published you cannot verify that the server you are talking to actually runs it, so an encrypted-mailbox provider is a trust-the-operator model no matter how good the client. That trust is bounded by a single company in a single jurisdiction, and both leading providers have a documented case of being legally compelled to act against a user, below.

Proton Mail. Swiss, operated by Proton AG under the non-profit Proton Foundation, in service since 2014. Zero-knowledge encryption at rest, open-source clients across platforms (the server is not open source), post-quantum cryptography rolling out, the most polished UX in the category, and a broader stack (Mail, VPN, Calendar, Drive, Pass) if you want one vendor for several things. The Bridge app provides IMAP/SMTP to desktop clients such as Thunderbird and nmail by running a local decryption proxy.

How Proton has misbehaved, on the record. In 2021 Proton received a legally binding order from the Swiss Federal Department of Justice, originating with French police and routed through Europol, and was compelled to log and hand over the IP address and device type of an account used by the Youth for Climate collective in Paris, which led to an arrest15. Message contents were never exposed because the zero-access encryption held, but the metadata was, and the episode mattered because Proton’s homepage had until then boasted that it kept no IP logs and put your privacy first; Proton quietly deleted that boast and reworded its privacy policy to state that a user under Swiss criminal investigation can be compelled to have their IP logged. The structural lesson is the one that recurs in this section: zero-access encryption protects stored content, but a single-jurisdiction provider can be forced to log IP and metadata on a targeted account going forward, and marketing that implied otherwise was overstatement corrected only after it was caught.

On the controlled-opposition charge. Parts of the privacy and Bitcoin community characterise Proton as controlled opposition, a service that exists to gather privacy-seekers into one auditable place, pointing to the IP-logging episode, Proton’s 2022 World Economic Forum Technology Pioneer designation, and CEO Andy Yen’s December 2024 public praise for a Trump antitrust nominee15. The primary-source record supports the narrower claims, namely Swiss court-compulsion exposure, a marketing-versus-reality reversal, and establishment-adjacent signalling a sovereignty-minded reader may legitimately weigh, but it does not establish the strong claim of deliberate intelligence control: Proton is governed by a non-profit foundation, is headquartered outside the Five and Fourteen Eyes alliances, and the Yen episode was a narrow antitrust comment the company walked back as not reflecting an official position. Treat the strong-form label as an unproven inference, and treat the narrower facts as the actual basis for deciding how much to trust a Swiss single-company mailbox.

Pick Proton if you want the most-polished encrypted mailbox, you accept Swiss-single-company trust and the court-compulsion exposure above, and an open client is enough verification for you in the absence of an open server.

Tuta. German, operated by Tutao GmbH since 2011, renamed from Tutanota in November 202316. Encrypts more than the others, subject lines included, using its own TutaCrypt protocol with post-quantum key exchange rather than PGP, and its clients are open source. The hard trade: no PGP interoperability and no IMAP/SMTP at all, so you are locked to Tuta’s own clients (web, desktop, mobile) with no third-party-client escape hatch, at the cheapest paid tier in the category.

How Tuta has misbehaved, on the record, with the important caveat that Tuta fought it. In 2020 a regional court in Cologne ordered Tutanota to build a function to monitor a single account used in a blackmail case, and in 2021 Germany’s Federal Court of Justice upheld a version of this, requiring three months of monitoring across the implicated accounts17. The monitoring applies only to mail the account receives after the order and delivers only unencrypted messages, since Tuta cannot decrypt content already stored at rest; Tuta argued all the way to the Federal Court that it is not a telecommunications service, and lost. Germany is a Fourteen-Eyes jurisdiction under strong GDPR-plus protections. The lesson mirrors Proton’s: at-rest encryption protects stored mail, but a single-jurisdiction provider can be compelled to monitor future incoming mail on a targeted account, and resisting the order in court delays it rather than defeating it.

Pick Tuta if subject-line encryption, an open client, and price matter more than PGP interop and client freedom, and you accept German-single-company trust and the court-compulsion exposure above.

The structural catch worth repeating: encrypted-mailbox providers solve at-rest confidentiality against the provider, not metadata exposure and not in-transit confidentiality to non-users. They are a real improvement over Gmail; they are not a messenger.

Privacy-respecting standard providers

These run ordinary IMAP/SMTP (so any client works) and compete on policy and jurisdiction rather than on cryptographic-at-rest gimmicks. You trust the provider not to read your mail rather than making it cryptographically impossible. The trade is interoperability and client freedom in exchange for giving up the at-rest guarantee Proton and Tuta offer.

Posteo (German, in service since 2009, around one euro per month, runs on green energy, strips IP metadata from outgoing mail, anonymous signup and payment, no custom domains). Mailbox.org (German, since 2014, supports custom domains, integrated office suite, PGP via the webmail with server-side key handling as an option). Disroot (Dutch, a donation-funded collective running on free software since 2015, part of a broader libre-services suite, activist-aligned). Mailfence (Belgian, run by the long-established ContactOffice group, built-in PGP keystore, calendar and documents, custom domains). These run proprietary server stacks, Disroot excepted (its stack is free software), and in every case you are trusting the operator rather than verifying the server, the same bound as the encrypted-mailbox providers but without the at-rest guarantee.

Pick one of these if you want client freedom (use Thunderbird, nmail, neomutt, whatever), custom-domain support, or a specific privacy-respecting jurisdiction, and you’re comfortable with provider-trust rather than zero-knowledge as the model. Posteo and Mailbox.org are the two most-recommended in privacy circles; Disroot if the activist-collective alignment fits.

Newer and unproven, and why longevity matters

This doc treats how long a service has operated as a security signal, so newer entrants get a maturity stamp rather than a recommendation, and recently-shut-down services get named as the reason why.

AtomicMail. A new encrypted-mailbox service, EU-based (an Estonian company with servers in Germany, GDPR-compliant), whose mobile client shipped in 202518. It offers zero-access encryption, anonymous signup with no phone number, and seed-phrase account recovery. Two things keep it out of the recommended set on this doc’s own criteria. First, it is not fully open source: its code is not fully published, which by the open-source-is-a-floor lens means its behaviour is taken on faith, and it carries a higher trust requirement than even Proton or Tuta. Second, it uses its own proprietary Atomic Encryption (built on AES-256 and ECIES) rather than OpenPGP, which reproduces Tuta’s client lock-in without Tuta’s decade of operation, and there is no published independent security audit. The encryption claims may well be sound; the point is that with a closed, unaudited, very young service you cannot check, and the whole reason to leave Gmail is to stop taking such things on faith. Revisit AtomicMail if it opens its source and publishes an independent audit; until then it is one to watch, not one to trust with anything that matters.

Skiff, the cautionary tale. Skiff was a polished end-to-end-encrypted mail, calendar, and document suite, founded in 2020, partially open source, and well funded, having raised 14.2 million dollars from investors including Sequoia19. In February 2024 it was acquired by Notion, and by August 2024 the entire product was shut down, with mail forwarding limping on only until February 2025, leaving users to migrate everything out under a deadline. The lesson is the one this doc builds the longevity lens around: a slick, partly-open, VC-funded privacy startup is the profile most likely to be acquired and killed, because the funding model points at an exit rather than at operating the same service for twenty years. Weight a provider’s years in operation and its funding model accordingly, and prefer the boring decade-old options for anything you cannot afford to migrate on someone else’s timeline.

Self-hosted mail

Maximum sovereignty, highest operational cost in this entire project series. Running your own mail means you control everything and trust no provider; it also means you own the single hardest self-hosting problem there is.

Stalwart. The sovereignty-frontier pick20. Rust, single binary, AGPL-3, native JMAP (RFC 8621, the modern API that supersedes IMAP) plus IMAP4rev2, POP3, SMTP, CalDAV, CardDAV, WebDAV, the whole stack in one process where the traditional approach chains Postfix, Dovecot, and Rspamd. Built-in DKIM/SPF/DMARC/ARC, statistical spam filter, web admin, automatic TLS via ACME, encrypted-at-rest mailboxes, OpenID Connect for SSO. Two independent security audits by Radically Open Security. Runs comfortably on a 512MB-1GB VPS. The modern choice; smaller community than mailcow but the trajectory is clear.

mailcow. The battle-tested pick. Docker-compose stack (Postfix, Dovecot, Rspamd, SOGo webmail, admin UI), mature, well-documented, large community, the safe default if you want a full email server replacing Google Workspace. Heavier than Stalwart (2GB+ RAM). Maddy (Go, single binary, simpler and smaller scope than either; good for a personal domain). Mailu (Docker, Postfix/Dovecot-based, simpler than mailcow).

The hard truth, named prominently because every “self-host your mail” pitch glosses it: deliverability is the problem, not the software. To not land in Gmail’s and Outlook’s spam folders you need four correct DNS records (MX, SPF, DKIM, DMARC), a matching PTR reverse-DNS record, and (critically) a sending IP with clean reputation that isn’t on any blocklist. Residential ISP connections fail this by default: port 25 is commonly blocked outbound, and residential IP ranges are blocklisted as a class. So self-hosted mail belongs on a VPS with a clean IP and unblocked port 25, not on a home connection, unless you front outbound mail through a relay.

StartOS, and why home-hosted mail is the hard case. StartOS (Start9, formerly EmbassyOS; renamed; 0.4.0 unveiled March 2026; MIT-licensed, Rust backend; serves a graphical interface as a private website; runs services over Tor v3 with clearnet hosting also supported)21 is the leading plug-and-play sovereign-computing OS: it lets non-sysadmins discover, install, configure, back up, and monitor self-hosted services on a home appliance from a web UI. For Nextcloud, a Bitcoin full node, Vaultwarden, a Lightning node, media servers, and most of the self-hosting catalogue, it is an excellent answer and squarely in this project’s sovereignty frame. Start9 sells preflashed Server One hardware or you can DIY-install on your own box.

Mail is the exception, and it’s worth being precise about why. StartOS does not currently ship a turnkey mail server in its marketplace (its FAQ confirms a service has to meet packaging requirements and mail isn’t a first-class offering today). More fundamentally, a StartOS box lives on your home connection, exactly the residential-IP, port-25-blocked, blocklisted-by-class situation that makes home-hosted outbound mail land in spam. So StartOS is a great home for self-hosted services generally and a legitimate host for a relay-fronted mail setup (where a clean-IP VPS or a transactional relay handles outbound delivery and your StartOS box holds the mailboxes), but it is not a one-click fix for the thing that actually makes self-hosted mail hard. If your goal is sovereign mail specifically, a Stalwart instance on a clean-IP VPS is the more honest path than mail on a home appliance. Here it is named for the self-hosted-mail-deployment question only.

The ladder to self-hosted mail

Sovereign mail is a climb, not a switch, and the single move that makes the climb possible is owning a custom domain from the start, because then every rung is a provider swap rather than a change of address that breaks all your accounts and contacts. The rungs, in increasing order of control and operational cost:

Rung 0, harden a hosted mailbox. Use Proton or Tuta for at-rest encryption, add per-service aliases (below), and add PGP for bodies where a correspondent supports it. You own nothing yet, but you have shrunk what the provider and a passive network can see.

Rung 1, standard provider on your own domain. Move to Posteo or Mailbox.org on a custom domain you control. You still trust the operator, but the address is now yours, so the rest of the ladder no longer costs you your identity.

Rung 2, provider holds the mailboxes, you hold the domain and the keys. Keep a hosted mailbox but treat it as replaceable infrastructure: your domain, your PGP keys, your aliases, and a local archive in Thunderbird or nmail. At this point you can change providers in an afternoon.

Rung 3, self-host on a clean-IP VPS. Run Stalwart (or mailcow) on a VPS with a clean IP and unblocked port 25. You now control and can audit the whole stack, and the only thing you are renting is the IP reputation, which is the hard part of email. This is the honest sovereign-mail endpoint for most people.

Rung 4, self-host at home, relay-fronted. Hold the mailboxes on your own hardware or a StartOS box at home, and front outbound delivery through a clean-IP relay so your residential, port-25-blocked, blocklisted-by-class connection does not sink your mail into spam folders. Maximum control, maximum operational burden, worth it only if you will keep it running.

The deliverability problem from the self-hosted section sets where the climb gets steep: rungs 0 through 2 inherit someone else’s clean IP, rung 3 rents a clean one, and rung 4 is hard precisely because a home connection cannot supply one. Climb only as high as you will maintain.

Encryption layers on top of any email

Independent of provider, you can add body encryption.

PGP / OpenPGP. Thunderbird has OpenPGP built in (no Enigmail plugin needed since Thunderbird 78). This is the standard way to encrypt email bodies end-to-end across any provider, provided your correspondent also uses PGP. The honest limitations: key management is a real burden, there’s no forward secrecy (compromise of your long-term key exposes all past mail encrypted to it), and the metadata still leaks. These limitations are precisely why the messenger tiers in the first half of this doc exist.

age-encrypted attachments. For sending a sensitive file over any email, encrypting it with age (a modern, simple file-encryption tool, see Choosing encryption tools) and attaching the ciphertext sidesteps the PGP-email complexity entirely: the email is just a transport for an independently-encrypted blob. Lower-ceremony than PGP-email for the specific case of “send this one file securely.”

Autocrypt. The in-band key-exchange profile that DeltaChat (Tier 6 above) uses to make PGP transparent. Some other clients support it. It trades a little security (keys exchanged opportunistically in headers) for the usability that hand-managed PGP lacks.

Email clients

Per the apt-primary, build-from-source-only-when-needed approach: most of these are in Debian/Devuan repositories.

Thunderbird / Betterbird. The default graphical client, cross-platform, OpenPGP built in, works with any IMAP/SMTP provider and with Proton via the Bridge. Betterbird is a Thunderbird fork with extra features and fixes, for users who want them. The right default for most people.

Terminal clients. neomutt (the actively-maintained mutt fork, maximally configurable, the long-standing power-user choice). aerc (modern, async, Git-friendly, good for plaintext and patch workflows). nmail (d99kris; C++/ncurses; IMAP+SMTP; local cache in optionally-AES256-encrypted SQLite; address book auto-generated from messages; Alpine/Pine-style UI; setup wizards for Gmail/iCloud/Outlook; compose in $EDITOR, view in $PAGER; MIT; v5.11.4)22. meli (Rust terminal client, newer). nmail’s distinguishing traits for this audience: the encrypted local cache and the familiar Pine-style interface; note it’s deliberately not designed to interoperate with other clients’ Maildir, so treat it as a self-contained client rather than one tool in a multi-client setup.

Mobile. Thunderbird for Android (formerly K-9 Mail, which Thunderbird adopted and rebranded) and FairEmail (privacy-focused, granular controls) on Android; on iOS the choices are more constrained, with Proton’s and Tuta’s own apps the sovereignty-aligned options for those providers.

Aliasing and forwarding

A unique email alias per service means a breach or data-sale at one service exposes only that alias, and you can sever any sender by killing one alias without changing your real address. Sovereignty-aligned because it decouples your identity from any single provider and from the senders.

SimpleLogin (open-source, now Proton-owned, self-hostable). addy.io (formerly AnonAddy; open-source, self-hostable, generous free tier). Firefox Relay (Mozilla, simplest, fewest features). Pick SimpleLogin or addy.io for the self-hostable open-source options; both can run on your own domain so the aliasing itself doesn’t introduce a new trusted third party.

What to avoid for email

Gmail, Outlook / Microsoft 365, Yahoo for anything sensitive. Content is scanned, the business model monetises what it learns about you, and the mailbox is readable by the provider and anyone who compels the provider. Fine for low-stakes mail and mailing-list signups; not for anything you’d mind being read. The “free that isn’t a free tier of a paid product” frame from Choosing networking tools applies identically: if the email is free and the company is an advertising company, you are the product.

Email maturity table

ToolTypeOpen sourceStatusCapture-risk shape
Proton MailEncrypted mailboxClient onlyProduction, mature (since 2014)Swiss single company; zero-knowledge at rest; court-compulsion exposure
TutaEncrypted mailboxClient onlyProduction, mature (since 2011)German single company; no IMAP/PGP; subject-line encryption; court-compulsion exposure
AtomicMailEncrypted mailboxNoNewer; not recommended (mobile 2025)EU single company; proprietary crypto; unaudited
PosteoPrivacy standard providerNoProduction (since 2009)German single company; provider-trust model
Mailbox.orgPrivacy standard providerNoProduction (since 2014)German single company; custom domains
DisrootPrivacy standard providerYes (FOSS stack)Production (since 2015)Dutch donation collective
MailfencePrivacy standard providerNoProduction (since 2013)Belgian single company; built-in PGP
StalwartSelf-hosted serverYes (AGPL-3)Production (Rust, audited)None (your VPS); deliverability is the cost
mailcowSelf-hosted serverYesProduction, matureNone (your VPS); deliverability is the cost
MaddySelf-hosted serverYesProduction, smallerNone (your VPS)
StartOSSelf-hosting platformYes (MIT)Production (0.4.0)None; but home-IP mail deliverability is hard; no turnkey mail service today
Thunderbird / BetterbirdClientYesProduction, matureClient only
neomutt / aerc / nmail / meliClient (terminal)YesProductionClient only
SimpleLoginAliasingYesProductionProton-owned; self-hostable
addy.ioAliasingYesProductionSelf-hostable

  1. SimpleX Chat blog, SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding, to Preserve Freedom of Speech, 30 April 2026: https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html. “No single company should control protocols and network that people depend on to speak freely. … we’re launching SimpleX Network Consortium within a few months, the agreement between the new SimpleX Network Foundation and SimpleX Chat company that will govern protocols and licensing, perpetual, irrevocable, surviving if any party is sold or shut down.” ↩ ↩2

  2. IETF More Instant Messaging Interoperability (MIMI) Working Group: https://datatracker.ietf.org/wg/mimi/about/. Active drafts as of early 2026: An Architecture for More Instant Messaging Interoperability (draft-ietf-mimi-arch, R. Barnes, Cisco): https://datatracker.ietf.org/doc/draft-ietf-mimi-arch/; More Instant Messaging Interoperability (MIMI) using HTTPS and MLS (draft-ietf-mimi-protocol, R. Barnes / M. Hodgson / K. Kohbrok / R. Mahy / T. Ralston / R. Robert): https://datatracker.ietf.org/doc/draft-ietf-mimi-protocol/; Room Policy for the MIMI Protocol (draft-ietf-mimi-room-policy, R. Mahy): https://datatracker.ietf.org/doc/draft-ietf-mimi-room-policy/; MIMI message content (draft-ietf-mimi-content, R. Mahy): https://datatracker.ietf.org/doc/draft-ietf-mimi-content/. ↩

  3. SimpleX Chat, project home page: https://simplex.chat/. “The first messenger without user IDs. Other apps have user IDs: Signal, Matrix, Session, Briar, Jami, Cwtch, etc. SimpleX does not, not even random numbers. … To deliver messages, instead of user IDs used by all other platforms, SimpleX uses temporary anonymous pairwise identifiers of message queues, separate for each of your connections, there are no long term identifiers.” ↩

  4. SimpleX Chat blog, SimpleX network: cryptographic design review by Trail of Bits, v6.1 released with better calls and user experience, 14 October 2024: https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html. The Trail of Bits review followed an earlier 2022 audit: https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html. ↩

  5. Keet by Holepunch, project site: https://keet.io/. Backed by Tether and Bitfinex; built on the Pear Runtime and the Hypercore protocol (Holepunch GitHub organization: https://github.com/holepunchto); serverless peer-to-peer over a distributed hash table, end-to-end encrypted text, voice, and video with Bitcoin Lightning and USDT payments; launched 2022. The Pear Runtime, Hypercore, and Hyperswarm are open source under permissive licenses; the Keet client application itself is not open source, packaged by NixOS as unfree (https://github.com/NixOS/nixpkgs/issues/208506), and a 2022 intention to open-source the app remains substantially unfulfilled as of 2026. ↩

  6. bitchat, GitHub organization: https://github.com/permissionlesstech. iOS repository: https://github.com/permissionlesstech/bitchat. Android repository: https://github.com/permissionlesstech/bitchat-android. Technical whitepaper: https://github.com/permissionlesstech/bitchat/blob/main/WHITEPAPER.md. Jack Dorsey’s launch announcement on X, 6 July 2025; Engadget coverage of the iOS App Store release, 28 July 2025: https://tech.yahoo.com/apps/articles/jack-dorseys-bluetooth-messaging-app-185000506.html. “and Other Stuff” is Dorsey’s open-source development collective backing the project. Uganda 2026 election adoption documented in 2026 coverage of opposition leader Bobi Wine urging citizens to use bitchat ahead of anticipated internet shutdowns; Google Trends documented the corresponding Uganda search-interest surge. Wikipedia summary entry: https://en.wikipedia.org/wiki/BitChat. ↩

  7. White Noise, project home page: https://www.whitenoise.chat/. GitHub repository: https://github.com/parres-hq/whitenoise and Rust core at https://github.com/marmot-protocol/whitenoise-rs. Architectural summary from the project FAQ as cited by gigazine.net coverage, 14 July 2025: https://gigazine.net/gsc_news/en/20250714-whitenoise-chat-secure-messaging-nostr/. Atlas21 founder interview with Max Hillebrand, 21 July 2025: https://atlas21.com/white-noise-is-born-the-private-messaging-app-based-on-nostr/. Bitcoin Magazine launch coverage: https://bitcoinmagazine.com/news/white-noise-anonymous-nostr-dms-and-encrypted-group-chat. Apple TestFlight beta: https://testflight.apple.com/join/c6Z7PpxC. ↩

  8. Marmot Protocol, organization on GitHub: https://github.com/marmot-protocol. Combines Nostr (identity, signaling), Blossom (file hosting on Nostr), and MLS (RFC 9420 Messaging Layer Security) into a single protocol stack for sovereign messaging. White Noise is the reference application. ↩

  9. Nostr Improvement Possibilities, NIP-17: Private Direct Messages: https://github.com/nostr-protocol/nips/blob/master/17.md. Specifies gift-wrapped DMs using NIP-44 encryption (versioned ChaCha20-Poly1305) inside NIP-59 sealed events, with a third gift-wrap event using random keys to hide sender and recipient identities from relays. ↩

  10. Nostr Improvement Possibilities, NIP-49: Private Key Encryption: https://github.com/nostr-protocol/nips/blob/master/49.md. Specifies a ncryptsec format for password-encrypted Nostr private keys, the Nostr analog of an encrypted SSH or GPG private key. ↩

  11. Pubky Core documentation: https://pubky.org/. Getting-started flow documenting Pubky Ring install, the invite/SMS/Lightning signup gates, path-scoped app authorization, and /pub/ being public by default: https://pubky.org/getting-started/. FAQ stating the MIT license, that Pubky is currently optimized for public data with private and encrypted features planned via Pubky Noise, and that Slashtags was the predecessor Synonym project on Hypercore: https://pubky.org/faq/. Retrieved June 2026. ↩ ↩2 ↩3 ↩4

  12. Pubky GitHub organization: https://github.com/pubky. pubky-core (MIT, Rust, “an open protocol for per-public-key backends for censorship resistant web applications”), pkarr (MIT, Rust; README states Ed25519 keys and publication to the BitTorrent peer-to-peer network of over ten million nodes), pkdns (MIT, Rust, a DNS server resolving pkarr self-sovereign domains), pubky-ring (MIT, TypeScript/React Native; v1.15 released 2 June 2026; site https://pubkyring.app/), pubky-noise (Rust, early), and a community Umbrel App Store repository hosting the Pubky Homeserver. All actively pushed as of June 2026. ↩ ↩2 ↩3 ↩4

  13. Bitfinex blog, “What is Pubky?”, interview with Synonym CEO John Carvalho, August 2025: https://blog.bitfinex.com/education/what-is-pubky/. Carvalho calls Pubky “a strict upgrade” to Nostr and argues that Nostr lacks a discovery method once a hosting server censors a user, whereas Pubky resolves the current data location via PKDNS. ↩

  14. Cointelegraph, “Tether launches Synonym to boost Bitcoin adoption through Lightning Network,” 16 November 2021: https://cointelegraph.com/news/tether-launches-synonym-to-boost-bitcoin-adoption-through-lightning-network, reporting Synonym Software as a company founded by Tether Holdings. Launch coverage recording Tether and Bitfinex CTO Paolo Ardoino as Synonym’s CTO at launch: Bitcoin Takeover, “John Carvalho Presents Synonym,” 16 November 2021: https://bitcoin-takeover.com/john-carvalho-presents-synonym-the-hyperbitcoinization-company/. Tether’s own newsroom pairing the companies in the Pear Credit announcement, 28 October 2022: https://tether.io/news/tether-holepunch-and-synonym-launch-pear-credit-a-p2p-credit-system/. The synonym.to footer carries a Tether endorsement mark; current legal entity Synonym Software, S.A. DE C.V. Retrieved June 2026. ↩

  15. Proton Mail’s 2021 logging episode and 2024 political controversy. TechCrunch, “ProtonMail logged IP address of French activist after order by Swiss authorities,” 6 September 2021: https://techcrunch.com/2021/09/06/protonmail-logged-ip-address-of-french-activist-after-order-by-swiss-authorities/. Proton stated it received a legally binding order from the Swiss Federal Department of Justice, originating with French police via Europol, concerning an account used by the Youth for Climate collective in Paris; message contents were not accessed. Threatpost, “ProtonMail Forced to Log IP Address of French Activist,” 7 September 2021, documents the removal of the “we don’t log your IP” claim from the homepage and the reworded privacy policy: https://threatpost.com/protonmail-log-ip-address-french-activist/169242/. Andy Yen’s December 2024 praise of the Gail Slater antitrust nomination and Proton’s subsequent neutrality statement: The Intercept, “Proton Mail Says It’s ‘Politically Neutral’ While Praising Republican Party,” 28 January 2025: https://theintercept.com/2025/01/28/proton-mail-andy-yen-trump-republicans/. Proton’s non-profit governance and non-Five/Fourteen-Eyes jurisdiction are the structural facts that weigh against the strong-form controlled-opposition reading. ↩ ↩2

  16. Tuta (formerly Tutanota), project site: https://tuta.com/. Rebranded from Tutanota to Tuta on 7 November 2023 (former tutanota.com redirects to tuta.com). German company Tutao GmbH, established 2011, over 10 million users as of June 2023. TutaCrypt post-quantum encryption protocol; encrypts subject lines, bodies, and attachments at rest; no PGP interoperability and no IMAP/SMTP (clients only). Wikipedia entry: https://en.wikipedia.org/wiki/Tuta_(email). ↩

  17. Tuta (Tutanota) court-ordered monitoring. TechCrunch, “German secure email provider Tutanota forced to monitor an account, after regional court ruling,” 8 December 2020: https://techcrunch.com/2020/12/08/german-secure-email-provider-tutanota-forced-to-monitor-an-account-after-regional-court-ruling/. The Cologne regional court ordered a monitoring function for a single account in an extortion case, applying only to mail received after the order and delivering only unencrypted messages, since at-rest content cannot be decrypted. CyberScoop, “Court rules encrypted email provider Tutanota must monitor messages,” May 2021, reports the Federal Court of Justice (BGH) requiring three months of monitoring across the implicated accounts after Tutanota argued it is not a telecommunications service: https://cyberscoop.com/court-rules-encrypted-email-tutanota-monitor-messages/. ↩

  18. Atomic Mail, project site: https://atomicmail.io/. Estonian company (Tallinn) with servers in Germany, GDPR-compliant; zero-access architecture using a proprietary “Atomic Encryption” built on AES-256 and ECIES rather than OpenPGP; anonymous signup with seed-phrase recovery. Mobile web client launched 25 March 2025 per the company announcement carried by Barchart: https://www.barchart.com/story/news/31558429/atomic-mail-releases-mobile-version-for-private-email. Independent assessment that its code is not fully open and that it carries a higher trust requirement than Tuta or Proton: the GitHub-hosted email-provider comparison at https://opensourcereviews.github.io/email/index.html. ↩

  19. Skiff (email service). Founded 2020 by Andrew Milich and Jason Ginsberg; raised 14.2 million dollars from investors including Sequoia; acquired by Notion in February 2024; all services shut down 9 August 2024 with mail forwarding continuing only until 9 February 2025; partially open source. TechCrunch, “Notion acquires privacy-focused productivity platform Skiff,” 9 February 2024: https://techcrunch.com/2024/02/09/notion-acquires-privacy-focused-productivity-platform-skiff/. Wikipedia entry, “Skiff (email service)”: https://en.wikipedia.org/wiki/Skiff_(email_service). ↩

  20. Stalwart Mail Server, project site: https://stalw.art/. GitHub: https://github.com/stalwartlabs/stalwart. All-in-one mail and collaboration server in Rust (SMTP, IMAP4rev2, POP3, JMAP, CalDAV, CardDAV, WebDAV), AGPL-3, by Stalwart Labs Ltd. Built-in DKIM/SPF/DMARC/ARC, statistical spam filter, ACME TLS, encrypted-at-rest mailboxes, OIDC SSO. Two independent security audits by Radically Open Security. NLnet/NGI0 funding history: https://nlnet.nl/project/Stalwart/. ↩

  21. StartOS by Start9 Labs (formerly EmbassyOS), project site: https://start9.com/. GitHub: https://github.com/Start9Labs/start-os. MIT-licensed Linux distribution for self-hosting; graphical interface served as a private website; services historically run over Tor v3 with clearnet hosting supported (Tor requirement dropped following OS v0.4.0). 0.4.0 unveiled by Start9 CEO Matt Hill on 27 March 2026. Marketplace of self-hosted services: https://marketplace.start9.com/. Per the Start9 services FAQ, not every self-hosted service is packaged and mail is not a first-class turnkey offering. Founded 2020 in Denver, Colorado. ↩

  22. nmail by Kristofer Berggren (d99kris), GitHub: https://github.com/d99kris/nmail. Terminal-based email client for Linux and macOS, C++/ncurses, Alpine/Pine-style UI. IMAP and SMTP; local cache in optionally-AES256-encrypted SQLite; auto-generated address book; setup wizards for Gmail, iCloud, and Outlook/Hotmail; compose in $EDITOR, view in $PAGER. MIT License; v5.11.4 (March 2025). Per its documentation, deliberately not designed to interoperate with other clients’ Maildir. ↩

Choosing Networking Tools

  • For: people choosing a VPN, mesh or overlay network without reintroducing a trusted third party.
  • Before you start: nothing. The encrypted DNS setup it assumes is in Devuan secure workstation.
  • Reading time: about 36 minutes end to end; the TL;DR gives the picks in two.
  • You end with: which tool fits each of five legitimate uses, and which to avoid.

How to choose a VPN, mesh, or overlay network in a way that doesn’t reintroduce the central trusted third party you were trying to escape. Covers commercial VPNs, self-hosted point-to-point, mesh with central coordinator, coordinator-less mesh, Nostr-rooted mesh, anonymity overlays, censorship-evasion proxies, and off-grid radio mesh.

This doc is the dedicated landscape for the cross-cutting concern that the security overview opens with its “Tor versus VPN versus self-hosted mesh” section. That section frames the choice at a high level; this doc fills in the rest of the space.

TL;DR

Most readers don’t need a VPN at all. HTTPS-everywhere plus encrypted DNS (set up in Devuan secure workstation) plus a hardened browser closes most of what commercial VPN marketing claims to solve. The legitimate use cases:

  1. Mesh between your own devices over hostile networks: self-hosted WireGuard or Headscale; Tailscale if convenience outweighs the corporate-coordinator capture risk.
  2. Anonymity: Tor (or I2P for app-aware use). Commercial VPN is structurally weaker than Tor and not a substitute. Against a global passive adversary (nation-state-scale traffic observation), NYM’s mixnet defends where Tor’s onion routing doesn’t.
  3. Bypassing geographic blocks or hostile-ISP restrictions: Mullvad or IVPN; pay in cash or Bitcoin.
  4. Active censorship (deep packet inspection, Tor blocking): Tor with pluggable transports and bridges, or censorship-evasion proxies (Shadowsocks, v2ray, Hysteria, Outline).
  5. Off-grid communication when the internet is unavailable or untrusted: Reticulum over LoRa, Meshtastic, or Briar’s Bluetooth mesh.

Sovereignty-frontier projects to track but not yet deploy as a daily driver: nostr-vpn1 plus its underlying FIPS2 mesh protocol. Both alpha-grade in 2026 but architecturally aligned with the Bitcoin / Nostr lens of no-trusted-third-parties.

What this doc is not: a deep-dive into any specific tool’s setup. Each entry names the tool’s positioning and the capture-risk shape; setup procedures live elsewhere.

The capture-risk frame for networking

Every networking tool answers two architectural questions: what identifies a node, and who coordinates which nodes can talk to each other. The combination of those answers is the tool’s threat model.

Commercial VPNs answer the first with a company-issued account and the second with a company-operated coordinator. The company holds both your identity and your traffic. The company can be subpoenaed, hacked, acquired, or compelled to log. This is the architecture most “VPN for privacy” marketing sold to people who wanted stronger.

Self-hosted point-to-point answers both with cryptographic keys you generated and infrastructure you operate. No company holds anything. The cost is setup and ongoing maintenance.

Mesh with central coordinator (Tailscale, NetBird, ZeroTier in their default modes) answers the first with your own cryptographic keys but the second with a company-operated coordinator. The coordinator sees who talks to whom and when (metadata, not content) and can be subpoenaed for that metadata. Headscale and self-hosted NetBird close this by letting you operate the coordinator yourself.

Coordinator-less mesh (Yggdrasil, cjdns, Nebula after PKI bootstrap, Innernet after admin signature) answers both with cryptographic identities and a routing protocol that converges without any central party. Hardest to misconfigure into a capture-risk; also hardest to set up.

Nostr-rooted mesh (nostr-vpn on top of FIPS) answers the first with a Nostr keypair (the same secp256k1 identity used for Nostr signing and for messaging via the Nostr-rooted apps covered in Choosing communication tools) and the second with FIPS’s self-organising mesh routing, with peer discovery and NAT traversal happening over public Nostr relays via gift-wrapped messages. Sovereignty-aligned by construction.

Anonymity overlays (Tor, I2P, Lokinet) answer the first with rotating ephemeral identities and the second with volunteer-run relay networks where no single party knows both who you are and what you’re doing. Mixnets (NYM) go further on the second question, adding cover traffic and timing-mixing so that even a global observer watching the whole network can’t correlate flows: the defence onion routing doesn’t provide.

Censorship-evasion proxies (Shadowsocks, v2ray, Trojan, Hysteria, Outline) answer neither question: they’re not privacy tools; they’re “make encrypted traffic look like benign TLS so the DPI box doesn’t drop it” tools. They live alongside the other tiers and stack on top.

Tier 1: Commercial VPNs

The “trust one company instead of your ISP” tier. Tolerable for narrow use cases (bypassing geographic blocks, hostile-ISP restrictions, traveller-laptop on a hotel network); not a privacy tool.

Mullvad

Swedish, founded 2009. The most-recommended commercial VPN in privacy circles because of three properties uncommon among peers: account identifiers are 16-digit account numbers with no associated email or phone, payment in cash by mail is supported, and the no-logs claim has held under independent audit and under a 2023 Swedish police raid that retrieved no user data3. Mullvad Browser is the Tor-collaboration project covered in the security overview.

What you trade: still a single trusted company. The Swedish jurisdiction provides EU data-protection benefits but does not eliminate the structural capture risk any commercial VPN carries.

Pick this if you specifically need a commercial VPN, understand the trust-shift not trust-elimination property, and you’re paying in cash or Bitcoin.

IVPN

Gibraltar-based, similar privacy posture to Mullvad: account numbers not emails, accepts Bitcoin and Monero, regular independent security audits, source-available clients. Smaller server footprint than Mullvad. Pick this as a redundancy second to Mullvad or where Mullvad’s server locations don’t suit.

Proton VPN

Swiss, owned by Proton AG (the Proton Mail company). The most-mainstream privacy-marketed commercial VPN. Free tier exists; the free tier’s economics depend on paid subscribers, so the free tier is real but limited. Proton AG has a public foundation structure (Proton Foundation, Swiss) governing the parent company.

Flag worth naming: Proton AG drew sustained criticism in 2024 from segments of the privacy community over CEO Andy Yen’s public political alignments. This is a community-politics flag, not a technical one, and how much it matters depends on your frame.

What to avoid

Free commercial VPNs that aren’t a free tier of a paid product (NordVPN, ExpressVPN, free apps in mobile stores). The business model that pays for the infrastructure is either logging-and-selling or malware. The “you are the product if it’s free” frame applies here particularly cleanly.

Tier 2: Self-hosted point-to-point

The “your own infrastructure, no company in the middle” tier. The right answer for accessing your home network from your laptop, or for connecting two specific machines you own.

WireGuard

The protocol. Designed by Jason Donenfeld, in-kernel on Linux since 5.6, in-tree on FreeBSD since 13.0, and the foundation that everything in Tier 3 and most of Tier 4 builds on. Configuration is a handful of public/private key pairs and a peer list. Static peer configuration: no coordinator needed if both endpoints have routable addresses.

Userspace implementations exist for cases where the kernel module isn’t an option: wireguard-go (Go, the reference userspace implementation), boringtun (Rust, originally Cloudflare’s implementation, used in early nostr-vpn releases before the FIPS data-plane migration). On Linux with a recent kernel, the in-kernel module is the right choice; the userspace implementations matter for iOS sandbox limitations, certain embedded environments, and as building blocks for higher-level tools.

What this gets you: an encrypted tunnel between two endpoints with formally-verified cryptography, near-line-rate throughput, and no third party. What it doesn’t give you: NAT traversal when both endpoints are behind NAT, or convenient onboarding of more than a handful of peers. Those are the problems Tier 3 solves.

Pick this for two specific machines you control with at least one having a routable address (your home server plus your laptop; two VPSes in different regions; a colo box plus your office). Configuration via wg-quick and /etc/wireguard/*.conf; no daemon to run beyond what the kernel provides.

OpenVPN

The pre-WireGuard incumbent. Still widely deployed; still works. Slower than WireGuard, larger attack surface (TLS-based, lots of options), older crypto stack. Reasons to still use it: a corporate VPN concentrator you don’t control still speaks OpenVPN, or you need TCP-mode tunnelling for hostile-network reasons (some restrictive networks block UDP). Otherwise use WireGuard.

SSH tunnels and sshuttle

The poor-person’s VPN. ssh -D opens a SOCKS proxy through any SSH server you can reach; sshuttle wraps it so all traffic to a configured subnet transparently routes via SSH. Not a real VPN (no kernel-level integration, no UDP support), but the right answer when all you need is to bounce traffic through a remote host you already have access to. Zero infrastructure beyond an existing SSH server.

Tier 3: Mesh with central coordinator

The “convenience of a managed service, with the privacy properties of WireGuard between peers” tier. Each peer holds its own keys and talks WireGuard (or similar) directly to other peers; a central coordinator handles peer discovery, key distribution, and access policy. The coordinator does not see content but does see metadata (who talked to whom, when, from what IP).

Tailscale

The category-defining product. WireGuard data plane, proprietary closed-source coordinator. Founded 2019 (David Crawshaw, Avery Pennarun, David Carney), Toronto-based, raised $275M+ across four rounds; Series C of $160M in April 20254. Acquired Border0 in March 2026. 318 employees as of April 20265.

Authentication requires a third-party identity provider (Google, Microsoft, GitHub, Apple, Okta) which means signing into your mesh requires signing into a corporate account.

Capture-risk shape: late-stage VC-funded enterprise networking company. The mandatory third-party identity provider authentication is the single largest sovereignty problem. The product is excellent technically; the structural position is the opposite of sovereignty-aligned. Pivoting toward AI-agent governance and enterprise data-residency as of 20266, which is the direction late-stage networking startups go when looking for exits.

Pick this only if convenience genuinely outweighs the capture-risk, you’re using it for mesh between machines you control rather than for any privacy property, and you understand you’re authenticating through Google or Microsoft.

Headscale

Open-source reimplementation of Tailscale’s control server, started by Juan Font7. Talks the same protocol the official Tailscale clients use, so you keep the Tailscale client UX while running the coordinator yourself. Active development; v0.26.x current as of early 2026. Pairs well with Headplane (open-source web UI) since Headscale itself ships only the daemon.

This is the sovereignty-aligned migration path off Tailscale that keeps the operational experience intact. Pick this if you already use Tailscale clients across devices and want to move the coordinator under your control without retraining users.

What you trade: you run the coordinator (a small Go binary plus Postgres or SQLite); you maintain compatibility with Tailscale client releases. Tailscale Funnel, Tailscale Serve, and some MDM features are Tailscale-proprietary and don’t work with Headscale.

NetBird

Fully open-source mesh VPN (Apache 2.0), Berlin-based, founded 2021. Both client and management server are open source. SSO via standard identity providers (Keycloak, Authentik, Okta, Auth0); first-class DNS management; access policies and groups built in8. Available as a cloud-hosted service from NetBird the company, or fully self-hostable in production.

Capture-risk shape: same as Tailscale architecturally if you use their hosted service, but with the escape hatch that self-hosting is fully supported and well-documented. Smaller company, earlier-stage, less VC pressure than Tailscale.

Pick this if you want a Tailscale-equivalent built from the ground up as open source, with a self-hosted-or-cloud choice you can flip later. Currently the best one-stop answer in Tier 3 for users who don’t already have Tailscale clients deployed.

ZeroTier

Open-source clients (BSL 1.1 licence), commercial coordinator service operated by ZeroTier Inc. (Irvine, California). Self-hostable controller exists (my-zerotier-controller) but is less polished than the hosted service. Older than Tailscale by several years; uses its own protocol rather than WireGuard.

Capture-risk shape: closer to Tailscale than to NetBird because the self-hosting story is workable but not the primary path. The BSL licence on clients is a yellow flag (BSL is source-available, not OSI-approved free software).

Pick this only if you specifically need ZeroTier’s older Layer-2 emulation (it can bridge Ethernet broadcast domains in a way WireGuard-based mesh can’t) for a use case like running legacy multicast-dependent protocols across sites.

Others worth knowing

The Tier 3 class has more entrants than the four above; these are the architectural classes worth naming, and the rest mostly recombine the same trade-offs. Briefly, for completeness: Firezone (WireGuard-based, open-source, self-hostable, policy-driven access), Netmaker (WireGuard mesh with a self-hostable control plane, kernel-WireGuard performance focus), OpenZiti (zero-trust overlay with application-embedded networking rather than host-level tunnels), Defguard (open-source WireGuard with enterprise SSO and MFA), and Twingate (closed-source zero-trust access, commercial, the most Tailscale-like in positioning). These are commercial-backed startups for the most part; expect some to be acquired or to pivot. Evaluate any of them against the same two questions the capture-risk frame poses: who holds the identity, and who operates the coordinator. If the answer to the second is “a company, with no self-hosting path,” it’s a Tier 1 risk shape wearing Tier 3 clothes.

Tier 4: Coordinator-less mesh

The “after bootstrap, no central party exists” tier. The setup step is heavier (you generate certificates or signed configurations once); the running mesh has no coordinator that could be compelled to produce logs.

Nebula

Open-source mesh built at Slack and open-sourced in 2019; the original authors (Nate Brown and Ryan Huber) left Slack in 2020 to found Defined Networking, which now maintains Nebula and sells a managed Nebula-based product. The open-source project itself remains independent.

PKI-based: you stand up a certificate authority once, sign peer certificates with it, and after that peers authenticate to each other using the CA’s signature. No coordinator runs continuously; “lighthouses” exist to help peers find each other across NAT but hold no authority, any peer can be a lighthouse. UDP-based, kernel-bypass userspace networking via tun/tap.

Slack-to-Salesforce note: Slack was acquired by Salesforce in 2021; Nebula itself predates that acquisition and the maintainers spun out a year before it closed. The Nebula codebase has no Salesforce dependency.

Pick this if you’re operating a fleet (dozens to thousands of machines) and want PKI-style host-to-host authentication without an always-on coordinator. Heavier ops burden than Tailscale-style products; pays off at scale.

Innernet

Rust-based mesh from tonarino, MIT licensed. CIDR-based organisation rather than flat-list-of-peers: a network is a CIDR, sub-CIDRs are sub-networks, hosts get IPs within their CIDR. Admin-signed peer invitations bootstrap new nodes; after bootstrap there’s no coordinator. Quieter project than the others in this tier; not enterprise-targeted.

Pick this if the CIDR-based mental model fits your use case (it does for some homelab configurations) and you want a smaller, more focused codebase than Nebula.

Yggdrasil

End-to-end encrypted IPv6 overlay network. Tree-based routing with public-key-derived addresses (your IPv6 address is a hash of your Yggdrasil public key). Can operate as a public global network (the default, anyone running Yggdrasil can reach anyone else), as a private mesh (filter peer connections to specific keys), or layered (private mesh peered with the public network).

Mature: development since 2017, current 0.5.x series, in Debian and Devuan repositories, kernel module supported via the yggdrasil-go userspace daemon.

Pick this for: a private IPv6 overlay between machines without setting up your own CA or coordinator (you exchange public keys with peers and that’s it); a fallback identity-routed network during real-internet disruptions; an experimental sovereignty overlay that interoperates with a public mesh.

cjdns

The Hyperboria project’s network protocol. End-to-end encrypted IPv6, source-routed (each packet carries its path), keypair-derived addresses similar to Yggdrasil. Older than Yggdrasil (2011 onward); smaller deployment today but the design influenced Yggdrasil heavily.

Pick this if you specifically want to connect to Hyperboria or you have a use case where source-routing is the right primitive. For most readers, Yggdrasil is the more practical answer in the same shape.

Tier 5: Nostr-rooted mesh (sovereignty frontier)

The newest entry in the landscape. Two projects, two authors; this section names the distinction because secondary commentary tends to conflate them.

FIPS (Free Internetworking Peering System)

The underlying mesh networking protocol, built by jcorgan (jmcorgan on GitHub)2. Rust, MIT licensed. Uses Nostr secp256k1 keypairs as node identities (your npub is your network address; node_addr is a SHA-256 hash for routing). Designed to operate over any transport, UDP overlay on the existing internet today, with Ethernet, Bluetooth, Tor, and serial transports as design targets. Tor transport support landed in v0.2.0 (March 2026)9.

Architecture in one sentence: a self-organising encrypted mesh where nodes establish peer connections, authenticate each other via Nostr keys, and route traffic for each other without any central authority or global topology knowledge. End-to-end encrypted between any two nodes regardless of hop count, with re-encryption at every hop.

Status: v0.1.0 alpha as of self-description, v0.2.0 current. The author’s framing is direct: “if it breaks, you get to keep both pieces.” Passed simulation testing and small-scale deployments; not production-stable.

Note on the acronym: clashes with the well-known U.S. cryptographic-standard FIPS, which has drawn public criticism10. Worth knowing if you reference the project in your own writing.

nostr-vpn

Tailscale-style mesh VPN application built by Martti Malmi (mmalmi)1, the Bitcoin developer who worked alongside Satoshi in 2009-2011 and received the first Bitcoin transaction. Uses FIPS as its data plane per the current README. Earlier releases (v0.2.x in March 2026) used WireGuard tunnels via boringtun with Nostr relays for signalling; the architecture migrated to FIPS-backed during the version-4 series, with v4.x current as of late May 2026 and rapid iteration ongoing (the project has been shipping multiple releases per week through the v4 series, so any specific version number in this doc should be assumed stale by the time you read it; check the GitHub releases page).

Authorship-split: Malmi built the user-facing nostr-vpn application. FIPS (which the secondary commentary tends to credit to “Bitcoin developer Malmi”) was built by jcorgan, who is a separate person. Both are sovereignty-aligned developers; only one of them is the Satoshi-era figure.

What the May 2026 v4 release line added (Malmi’s announcement, May 19): native multiplatform desktop apps (macOS, Linux, Windows), Android app, Nostr-based multihop routing for when NAT holepunching fails, improved network management UX, FIPS as the unified data plane. Shipped 11 releases in seven days from v0.2.2 through v0.2.13 in March; the v4 series followed in April-May with ongoing rapid iteration. Production-grade nowhere near.

What’s worth picking up today: read the README, run it between two machines you control, learn the model, contribute back. What’s not worth picking up today: replacing your daily mesh setup with it. The right time to migrate is when FIPS leaves alpha and nostr-vpn settles a release cadence longer than days.

Capture-risk shape: zero by construction if the implementation is correct. Your identity is a keypair you generated; coordination happens over a set of Nostr relays you can choose, run, or rotate; routing happens through other FIPS nodes that authenticate each other via Nostr keys. No company exists in the protocol’s threat model.

Why it matters now even though it’s not deployable yet: the design is the proof-of-concept for the sovereignty frame applied to networking infrastructure, the same way Bitcoin was the proof-of-concept for the sovereignty frame applied to money. The architecture is what to learn from. The bits work today; the polish doesn’t.

Adjacent sovereignty stack: the Nostr-rooted ecosystem these tools sit in extends beyond networking. White Noise (Choosing communication tools) is the messaging-layer counterpart, using MLS encryption over Nostr signalling via the Marmot protocol. Blossom is the Nostr-native file-hosting standard that Marmot uses for media transport. The shared infrastructure (Nostr relays, secp256k1 keypairs, Bitcoin-community-funded development) is one of the project’s sovereignty-frontier signals to watch.

Adjacent frontier: the Holepunch/Pear stack

Not Nostr-rooted, and not a transport. This note is parked in Tier 5 as a sovereignty-frontier signal to follow, alongside the Nostr-rooted stack above, until the project has a dedicated file-sharing doc to house it.

PearDrive is a peer-to-peer file-sharing and storage tool built on the Pear Runtime, the same Holepunch P2P stack that powers Keet.11 12 You create or join a “network” with a shared network key, then upload files and pull them directly from connected peers, with peer identities surfaced as public keys and network access shareable by QR code.13 An archive mode turns a node into a full replica that stores every file on the network, the project’s answer to availability when the original uploader goes offline.13

Substrate and funding shape: Pear Runtime is open-source infrastructure (the Hypercore family: Hyperswarm, HyperDHT, Hyperdrive) created by Holepunch, a company funded by Tether and Bitfinex, with Tether’s Paolo Ardoino as a co-founder and the project framed in explicitly Bitcoin terms.12 14 In this project’s lens that is a double signal: Bitcoin-aligned capital behind genuinely no-server infrastructure on one side, a single dominant corporate sponsor behind the runtime on the other, which is a concentration risk the volunteer-relay and grant-funded models elsewhere in this doc do not carry. PearDrive itself is published under its own AGPL-3.0 JavaScript repository (the peardrive GitHub org), separate from Holepunch’s own apps, so it inherits the substrate’s funding shape without being a Holepunch in-house product.13

Status: a pre-beta proof of concept by its authors’ own account.11 The CLI carries a v4.x tag, but its own README still flags network deletion as unimplemented and file-change syncing as not working until a later PearDriveCore release, so this is a thing to read, run, and learn from, not to entrust files to.13 Name-collision caveat for anyone citing it: this is PearDrive (peardrive.com, Pear/Holepunch stack), distinct from the older and unrelated PeerDrive (peerdrive.org), an alpha-stage Erlang distributed filesystem.15 Revisit when it reaches a tagged beta and file-change sync lands.

Tier 6: Anonymity overlays

Different goal from everything above. Tier 1 through 5 are “encrypt the link”; this tier is “hide who you are.” Cross-referenced from the “Tor versus VPN versus self-hosted mesh” section of the security overview.

Tor

Three-hop volunteer-run onion network. Your guard node knows who you are but not what you do; the middle relay knows neither; the exit knows what you do but not who you are. Deanonymisation requires controlling or observing both the guard and the exit, which is exponentially harder than compromising a single VPN.

This is the right answer for anonymity. Slow (three hops globally), incompatible with sites that block Tor exits (Cloudflare-protected sites in particular). Run it via Tor Browser for sensitive browsing or via Whonix for whole-system anonymity (see Whonix and the Whonix VM-compartmentalisation section of Devuan secure workstation).

Tor is not a substitute for the mesh tiers above. Tor anonymises you from destinations; it does not connect your laptop to your home server.

Onion services as a hosting primitive. Beyond using Tor as a client, you can publish a service on Tor that’s reachable only through Tor. Onion services (v3, ed25519-based, 56-character addresses ending in .onion) provide end-to-end encryption to the service plus location-hiding for the host. Use cases: SSH access to a home server without exposing a public IP; private file sharing (OnionShare); whistleblowing intake (SecureDrop, GlobaLeaks). The hosting primitive is the same architecture the anonymity uses, applied in reverse.

Bridges and pluggable transports for blocked networks. When your ISP or country blocks Tor directly, the project provides two layers of evasion. Bridges are unlisted Tor relays not published in the public directory; the censor doesn’t know to block them. Pluggable transports obfuscate Tor traffic to look like something else:

  • obfs4. The workhorse. Disguises Tor traffic as random bytes, frustrating naive DPI.
  • Meek. Tunnels Tor through major CDN services (currently meek-azure via Azure). The censor sees you talking to Azure; blocking Azure breaks half the internet. Slower than obfs4 due to CDN routing.
  • Snowflake. Routes Tor traffic through ephemeral browser-based volunteer proxies (Chrome and Firefox users running the Snowflake extension act as proxies). Each connection looks like ordinary WebRTC traffic. The censor would have to block WebRTC to block Snowflake, which is collateral-damage-heavy.

Pick obfs4 by default; Snowflake when obfs4 is blocked too; Meek when both are blocked. The Tor Browser ships all three; selection is a settings choice.

I2P

Application-aware anonymity overlay. Where Tor exits to the regular internet, I2P is mostly an internal network with its own services (eepsites, IRC, BitTorrent over I2P). The threat model is similar to Tor’s but the use cases differ: Tor is “be anonymous on the regular web”; I2P is “participate in an anonymous internal network.”

Pick I2P if you specifically need to participate in I2P-internal services. For anonymous regular-web browsing, Tor is the practical answer.

Lokinet

Onion-routing overlay developed by the Loki Project (now Oxen). Architecturally similar to Tor in being a hidden-service network but using its own service-node infrastructure rather than Tor’s volunteer network. Used as the transport for Session messenger (see Choosing communication tools) and as a standalone anonymity overlay.

Capture-risk shape: token-economic incentive structure for service nodes (Oxen token), a flag for some sovereignty-minded operators who prefer the volunteer-funding model Tor uses. Smaller network than Tor by orders of magnitude, which has both privacy and reliability implications.

Pick Lokinet if: you’re already on Session and want to use the same network for general traffic, or you specifically want an alternative anonymity overlay to Tor for diversity reasons. Otherwise Tor is the more practical answer.

NYM (NymVPN)

The mixnet entry, architecturally distinct from everything else in this tier. Tor, I2P, and Lokinet are onion-routing networks: they hide who-talks-to-whom by relaying through unrelated hops, but a global passive adversary who can observe the whole network’s traffic timing can still correlate flows. NYM defends against exactly that adversary by being a mixnet: it adds cover traffic (dummy messages that carry no payload) and mixes traffic timing at each hop, so the timing-correlation attack that works against Tor doesn’t work against NYM16.

Built by Nym Technologies S.A. (Swiss), based on the Loopix mixnet design; Chief Scientist Claudia Diaz was formerly a tenured professor at KU Leuven and is one of the better-known academic figures in mixnet and network-privacy research. The architecture descends from peer-reviewed mixnet literature rather than from a startup’s whiteboard, which is the durability signal worth naming.

NymVPN (the client) offers two modes. Anonymous Mode routes through a 5-hop Sphinx-based mixnet with continuous cover traffic (the strong-anonymity, higher-latency mode), recommended for messaging, crypto transactions, and email rather than streaming. Fast Mode is a 2-hop WireGuard path (running AmneziaWG, the same DPI-obfuscated WireGuard fork covered in Tier 7) for VPN-comparable speed when you want IP-hiding without the full mixnet latency cost. Rust, open source (GPL-3), clients for Linux, macOS, Windows, iOS, Android. Anonymous onboarding via 24-word access keys; payment in BTC or XMR. v2026.4 (March 2026) added desktop ad/tracker blocking.

Capture-risk shape: the mixnet itself is decentralised with no single operator that sees the whole topology. The flag worth naming is the NYM utility token, service nodes are incentivised via a token economy (bond-and-delegate-stake model), which is the same token-economic structure flagged for Lokinet and which some sovereignty-minded operators weigh against the volunteer-funded Tor model. Whether token incentives or volunteer incentives produce a more durable network is a genuinely open question; NYM is the most serious current bet on the token-incentivised side.

Pick NYM if: your threat model includes a global passive adversary (the nation-state-scale observer that can watch traffic across the whole network), which is the specific case Tor doesn’t fully defend against. Use Anonymous Mode for the high-value low-bandwidth traffic that case implies. For ordinary anonymity against a non-global adversary, Tor remains the larger, more battle-tested network; NYM’s advantage is specifically the metadata-timing defence against the strongest adversary class.

Tier 7: Censorship-evasion proxies

Not VPNs and not anonymity overlays, these are protocol-obfuscation tools designed to make encrypted traffic survive deep packet inspection. They stack on top of the other tiers: you might run WireGuard inside Shadowsocks inside a TLS tunnel to bypass a censor that blocks WireGuard’s UDP signature.

The threat model: a censor (state-level or corporate) running DPI on egress traffic to identify and block “circumvention tools.” The defence: make your traffic look like ordinary HTTPS or QUIC so the DPI signature doesn’t fire.

Shadowsocks

Originally developed inside China in 2012 to circumvent the Great Firewall. Lightweight SOCKS5 proxy with encrypted transport; later versions (Shadowsocks 2022, AEAD-based) much improved on the original cryptographic shortcomings. Deployable on any VPS with a 5-minute setup. The model: you (in a censored country) connect to a Shadowsocks server you operate (in a non-censored country); the server proxies your traffic out.

Pick Shadowsocks for: bridging into the open internet from a censored network where you control a VPS in a non-censored jurisdiction. The classic deployment is “VPS in Singapore for someone in mainland China.”

v2ray / Xray

Multi-protocol proxy framework that includes Shadowsocks plus several other transport protocols (VMess, VLess, Trojan-Go). More flexible than Shadowsocks alone; correspondingly more complex. Xray is the actively-maintained fork of v2ray. Used widely in the Chinese circumvention community.

Pick v2ray/Xray for: more advanced censorship cases where Shadowsocks alone is being detected; situations requiring TLS-mimicry transports like Trojan or VLess+Reality.

Trojan

A proxy protocol designed to be indistinguishable from ordinary HTTPS traffic. The Trojan server speaks valid TLS on port 443; if you don’t know the password it behaves like a normal HTTPS server (typically configured to proxy to a real website). DPI can’t distinguish Trojan traffic from ordinary HTTPS because it isn’t trying to: the cover traffic is genuine.

Pick Trojan for: high-stakes circumvention where active probing (the censor sending test traffic to your server to identify it) is part of the threat model. The “looks exactly like a web server” property is harder to detect than Shadowsocks-style obfuscation.

Hysteria

UDP-based proxy using QUIC, designed for hostile-network conditions where TCP performance collapses under high packet loss. Performs significantly better than Shadowsocks or v2ray over poor links. Trade-off: UDP is more often blocked outright than TCP, so Hysteria works best where UDP traffic is allowed but TCP is throttled.

Pick Hysteria for: poor-link or high-latency environments where the throughput improvement matters. Frequently paired with v2ray for the TCP fallback case.

Outline

Open-source Shadowsocks-based proxy from Jigsaw (Google’s research arm), with management tooling that simplifies VPS-based deployment17. Targets the journalist and activist use case specifically: the Outline Manager lets a less-technical user provision proxy servers for distribution to contacts in censored networks.

Capture-risk shape: Jigsaw is part of Alphabet, so the management tooling and the Outline Manager update channel are Google-operated. The proxy server itself is open source and runs on your own VPS; the censor cannot tell Outline traffic from ordinary Shadowsocks. Pick Outline if: the management UX is the gating factor for your deployment (you’re provisioning proxies for non-technical contacts) and you accept Google’s role in the tooling chain.

AmneziaWG

A WireGuard fork designed for the DPI-survival case18. Released by the Amnezia VPN team; the protocol implementation lives at amneziawg-go on GitHub. Preserves WireGuard’s cryptographic core (Curve25519, ChaCha20-Poly1305, Noise_IK) (the security argument doesn’t change) and modifies only the transport layer to evade DPI signature detection.

AmneziaWG 1.x (2024) replaced WireGuard’s fixed 32-bit message-type headers (the 1/2/3/4 values that make WireGuard trivially identifiable) with configurable magic values, padded handshake packets to vary their size, and added pre-handshake junk packets to confuse signature-based detection.

AmneziaWG 2.0 (March 2026) added active protocol mimicry. The transport layer now disguises traffic as one of several common UDP protocols (DNS queries, QUIC sessions, SIP calls) with packet sizes that vary during transmission rather than being fixed. The DPI box sees what looks like ordinary DNS or QUIC; the censor’s signature-based detection no longer fires.

Trade-off: like Shadowsocks and the other proxies in this tier, your traffic terminates at the AmneziaWG server, which knows your IP and your destinations. The privacy frame is single-trusted-party, same as commercial VPNs in Tier 1. The censorship-survival frame is what differs. Pick AmneziaWG if: you specifically want WireGuard’s performance and operational mental model (point-to-point tunnel between machines you control) plus DPI survival in a censored network. Self-host on a VPS in a non-censored jurisdiction; deploy via Amnezia’s official tooling or one of the community installers.

Adjacent commercial-VPN-side development: Mullvad rolled out QUIC obfuscation for WireGuard on its desktop clients in 2026, disguising WireGuard traffic as ordinary QUIC web traffic to bypass DPI in heavily-censored regions. Same convergence point (WireGuard plus DPI-survival obfuscation) from the commercial-VPN direction. The category to watch is “WireGuard with protocol obfuscation”; AmneziaWG and Mullvad’s QUIC obfuscation are two implementations of it.

Tier 8: Off-grid mesh and radio-grade

When the internet is unavailable, untrusted, or actively hostile. Different physical layer from everything above.

Reticulum

A network stack designed to work over any transport that can carry packets: LoRa radios, WiFi, Bluetooth, serial connections, I2P tunnels, the regular internet. Cryptographic-identity-routed (similar conceptual lineage to FIPS): your address is derived from your public key, and the network finds you without DNS or coordinators19. AES-128-CBC end-to-end encryption with ephemeral keys per packet; sender identity hidden from relays. Active development by Mark Qvist; v1.3.0 released May 21, 2026.

The use case Reticulum nails: a single logical network spanning LoRa radio + WiFi + serial + Tor simultaneously. A remote-forest LoRa node and an urban WiFi node and a serial-link emergency relay can all be on the same logical mesh and reach each other transparently. This is the design Yggdrasil and FIPS gesture at; Reticulum implements it for the post-internet case specifically.

Pair with LXMF (the Reticulum messaging format) and Nomadnet (terminal app) for off-grid messaging. RNode is the recommended hardware (open-source LoRa transceiver).

Pick this for off-grid messaging where the threat model includes “the internet is unavailable”, disaster scenarios, remote sites, jurisdictions where bulk internet surveillance is the default. Also pick this if you specifically want a single network that bridges radio and IP transports.

Meshtastic

LoRa-based mesh, open source, hardware-focused. Cheaper and simpler than Reticulum; the standard answer for community LoRa mesh networks. Floods messages across the mesh rather than routing intelligently like Reticulum, fine at small scale, degrades at large scale (>100 nodes).

Pick this for: a community mesh in a city neighbourhood, hiking-group communication, hobbyist LoRa networking. Pair with Meshtastic-compatible hardware like the LilyGO T-Beam or the Heltec LoRa boards.

MeshCore

Newer entrant (2025) targeting embedded systems with custom routing requirements. Integration at the firmware level rather than through consumer apps. Picks this if you’re building embedded mesh systems and Meshtastic’s flood-routing doesn’t fit; otherwise Meshtastic or Reticulum.

B.A.T.M.A.N. (batman-adv)

The WiFi-mesh routing protocol that real-world community networks actually run on. Where Reticulum and Meshtastic are LoRa-and-radio mesh and bitchat/Briar are Bluetooth mesh, B.A.T.M.A.N. (Better Approach To Mobile Ad-hoc Networking) is the protocol that turns a fleet of ordinary WiFi nodes into a self-organising mesh20. Developed by Germany’s Freifunk community since 2006; the batman-adv kernel module has been part of the mainline Linux kernel since 2.6.38 (2011); current release batman-adv 2025.4 (October 2025); controlled via batctl; packaged in Debian and Devuan.

Architecture: batman-adv operates at Layer 2 (it routes Ethernet frames, not IP packets), which means it emulates one giant virtual network switch spanning every node in the mesh. Every node appears link-local to every other; higher-layer protocols (IPv4, IPv6, DHCP) run on top unaware of the mesh topology underneath. The routing intelligence is decentralised by design: no single node holds the full network map; each node knows only the best next-hop toward each destination, computed from link-quality metrics (the TQ, transmit-quality value). This is the distance-vector approach that scales where flat flooding (Meshtastic’s model) collapses.

Capture-risk shape: none. It’s a kernel routing protocol, not a service; there’s no operator, no account, no coordinator. The mesh is whoever’s running batman-adv on the same physical or VPN-bridged Layer-2 segment.

Pick B.A.T.M.A.N. for: building a community WiFi mesh (the Freifunk model, neighbourhood-scale resilient internet that survives any single ISP or node), bridging multiple physical sites into one Layer-2 network over WiFi or VPN transports, or any case where you want a kernel-native mesh protocol rather than an application-layer overlay. This is the heaviest-infrastructure entry in Tier 8 and the most production-proven at community scale; Freifunk has run city-scale deployments on it for over a decade. Pair with OpenWrt on the node hardware for the standard community-mesh stack. For single-link or small-peer-count cases, the overlay-network options in Tier 4 (Yggdrasil especially) are simpler; B.A.T.M.A.N. earns its complexity at the scale of dozens-to-thousands of WiFi nodes.

Briar’s offline mesh

Briar is a P2P messenger (covered in Choosing communication tools) that includes Bluetooth and WiFi-direct mesh as transports alongside Tor over the internet. This puts it at the messenger / networking boundary: it’s primarily a messenger, but the transport layer it ships is real mesh networking. Latest stable release 1.5.9 (January 2024), so development has slowed; the architecture remains sound but project velocity is a flag.

For the messenger landscape including Briar’s positioning, see Choosing communication tools. For purely-networking off-grid use, prefer Reticulum or Meshtastic.

What to avoid

“VPN for privacy” as a frame. It was always a marketing frame, never a privacy frame. Encrypted DNS plus HTTPS-everywhere closes most of what commercial VPNs claim to solve, without any trusted third party. Use a commercial VPN for specific narrow reasons (geographic bypass, hostile-ISP bypass, hotel-network defence); don’t use one as a generic privacy upgrade.

Free commercial VPNs that aren’t free tiers of a paid product. The business model is logging-and-selling or malware. There are no exceptions.

WireGuard “VPN apps” with closed clients. WireGuard the protocol is fine; some commercial VPN apps claim to use WireGuard but ship closed clients with proprietary modifications. If the client isn’t open source, you’re back in Tier 1 even if the marketing says otherwise.

Browser-based “VPN” extensions. Browser proxies. Useful for specific bypass cases; not a VPN in any meaningful sense. Mozilla VPN, Brave’s built-in VPN, etc. are commercial VPN re-sells, not browser-side architecture.

Tailscale or any centralised-coordinator mesh as a “privacy” tool. Convenient mesh networking, real engineering, but the coordinator sees your metadata. The privacy frame collapses when one party knows the entire topology of who-talks-to-whom.

Censorship-evasion proxies as anonymity tools. Shadowsocks, v2ray, Trojan, Hysteria, Outline are great at evading DPI; they’re not anonymity tools. Your traffic still terminates at your proxy server, which knows your IP and your destinations. Don’t conflate these with Tor.

Where to start

A flowchart of common cases.

“I want my laptop on my home network when I travel.” Self-hosted WireGuard between laptop and home server (Tier 2). If the home server is behind a CGNAT or you have multiple devices, Headscale (Tier 3) is the next step up.

“I want a few machines to all see each other.” Same answer: Headscale plus WireGuard, or NetBird if you don’t already have Tailscale clients deployed. Self-host both.

“I want anonymity from my destination.” Tor (Tier 6). Use Tor Browser for browsing; use Whonix for whole-system Tor. If your adversary is nation-state-scale (can observe traffic across the whole network), NYM’s Anonymous Mode (Tier 6) adds the cover-traffic mixnet defence Tor lacks, at a latency cost.

“I want anonymity from my ISP.” Tor again. A commercial VPN gives you “trust one company instead of your ISP,” not anonymity. If you have a specific reason a VPN works better here (Tor blocked by your ISP, sites you need block Tor exits), Mullvad in Tier 1.

“I want to bypass a geographic block.” Commercial VPN, Tier 1. Mullvad or IVPN. Pay in cash or Bitcoin.

“Tor is blocked on my network.” Tor with pluggable transports. Try obfs4 first; Snowflake if obfs4 is blocked; Meek if both are blocked. Distribute Tor Browser ships all three (Settings → Connection → Bridges).

“Everything is blocked on my network.” Shadowsocks or v2ray to a VPS you control in a non-censored jurisdiction (Tier 7). Trojan if you’re being actively probed. Hysteria if the link quality is bad. AmneziaWG if you want WireGuard’s performance and operational mental model in the same DPI-survival posture.

“I want a sovereignty-aligned mesh that doesn’t depend on any company.” Today: Yggdrasil or Nebula (Tier 4), depending on whether you prefer IPv6-overlay simplicity (Yggdrasil) or PKI-based explicit-trust (Nebula). Tomorrow (when alpha lifts): nostr-vpn plus FIPS (Tier 5).

“I want communication that works when the internet doesn’t.” Reticulum with RNode hardware for the serious case; Meshtastic for the community-mesh case (Tier 8).

“I want to build a neighbourhood-scale resilient WiFi network.” B.A.T.M.A.N. (batman-adv) on OpenWrt node hardware (Tier 8). This is the Freifunk model, city-scale mesh that survives any single ISP or node failure, proven over a decade.

“I want all of the above stacked.” Reticulum can transport over Yggdrasil which can transport over Tor; the layering is the design. This is the maximalist configuration and is real ops work to operate; don’t start here unless you’ve operated each layer individually first.

Maturity table

Read the entries above for the substance; this table is the at-a-glance map.

ToolTierStatusCapture-risk shape
Mullvad1Production, auditedSingle Swedish company
IVPN1Production, auditedSingle Gibraltar company
Proton VPN1ProductionSingle Swiss company + foundation
WireGuard2Production, in-kernelNone (your infrastructure)
OpenVPN2Production, datedNone (your infrastructure)
sshuttle2ProductionNone
Tailscale3Production, late-stage VCClosed coordinator + mandatory third-party SSO
Headscale3ProductionNone (your coordinator)
NetBird3ProductionOptional company-hosted; self-hostable
ZeroTier3Production, BSL-licensedClosed coordinator (self-host possible)
Nebula4ProductionNone after PKI bootstrap
Innernet4MaintenanceNone after admin signature
Yggdrasil4Production, matureNone
cjdns4MaintenanceNone
nostr-vpn5Alpha, v4.x, fast churnNone by design
FIPS5Alpha (v0.2.0)None by design
Tor6Production, matureTrust-no-single-party (volunteers)
I2P6ProductionTrust-no-single-party (volunteers)
Lokinet6Production, smallerToken-economic service nodes
NYM (NymVPN)6Production (v2026.4)Mixnet; token-economic service nodes
Shadowsocks7Production, widely deployedYour VPS knows your traffic
v2ray / Xray7ProductionYour VPS knows your traffic
Trojan7ProductionYour VPS knows your traffic
Hysteria7ProductionYour VPS knows your traffic
Outline7ProductionYour VPS + Jigsaw management tooling
AmneziaWG7Production, v2.0 (March 2026)Your VPS knows your traffic
Reticulum8Production (v1.3.0)None
Meshtastic8ProductionNone
MeshCore8EarlyNone
B.A.T.M.A.N. (batman-adv)8Production, in-kernelNone
Briar mesh8MaintenanceNone

  1. Martti Malmi, nostr-vpn, GitHub repository: https://github.com/mmalmi/nostr-vpn. README description: “nostr-vpn is a Tailscale-style private mesh VPN built around a FIPS-backed data plane. It includes the nvpn CLI/daemon, a shared native app core, and native shells for desktop and mobile platforms.” Malmi’s release announcement on X, 19 May 2026: https://x.com/marttimalmi/status/2056616263925854570. Malmi’s bio: “Bitcoin dev in 2009-2011.” Current release v4.0.47 on the GitHub releases page as of 28 May 2026; the project ships multiple releases per week, so the live version number drifts faster than this doc updates. ↩ ↩2

  2. jcorgan, FIPS: The Free Internetworking Peering System, GitHub repository: https://github.com/jmcorgan/fips. Project website: https://fips.network/. Announcement on Nostr (jcorgan): “FIPS is a mesh networking protocol that makes a Nostr keypair your network identity. Nodes find each other and route traffic using npubs directly. No DNS registrars, no IP address allocation, no routing authorities. Just keypairs and encrypted links.” Protocol design documentation: docs/design/fips-intro.md. ↩ ↩2

  3. Mullvad blog, Mullvad VPN was subject to a search warrant. Customer data not compromised, 20 April 2023: https://mullvad.net/en/blog/mullvad-vpn-was-subject-to-a-search-warrant-customer-data-not-compromised. Documents Swedish police executing a search warrant; the company demonstrated it held no customer activity data the warrant could compel production of. ↩

  4. Tracxn company profile, Tailscale - 2026 Company Profile, Team, Funding & Competitors: https://tracxn.com/d/companies/tailscale/__HoO0OVaODdbZEsDLJ7_OTsp344lrcNrb7eGx_aw5Lrk. $275M total across four rounds; Series C of $160M on 8 April 2025; valuation $778M as of May 2022. PitchBook profile documents Tailscale’s acquisition of Border0 on 17 March 2026: https://pitchbook.com/profiles/company/268781-05. ↩

  5. Tracxn (same profile as above): “As of Apr 30, 2026, the latest employee count at Tailscale is 318.” ↩

  6. SiliconANGLE, Secure networking startup Tailscale launches identity-linked governance for AI tools and agents, 17 February 2026: https://siliconangle.com/2026/02/17/secure-networking-startup-tailscale-launches-identity-linked-governance-ai-tools-agents/. ↩

  7. Juan Font, Headscale, GitHub repository: https://github.com/juanfont/headscale. Open-source reimplementation of the Tailscale control server. v0.26.1 as of early 2026. ↩

  8. NetBird, NetBird - Connect your devices into a single secure private WireGuard®-based mesh network: https://netbird.io/. Apache 2.0 licensed; both client and management server open source. Berlin-based company; founded 2021. ↩

  9. Nostr Compass #15 newsletter, 25 March 2026: https://nostrcompass.org/en/newsletters/2026-03-25-newsletter/. Documents FIPS v0.2.0 adding Tor transport support, reproducible builds, sidecar example connecting through a Nostr relay, and Nostr release publishing in the OpenWrt package workflow. ↩

  10. Lobsters discussion of FIPS, February 2026: https://lobste.rs/s/fxljxx/fips_free_internetworking_peering. The acronym clashes with the U.S. National Institute of Standards and Technology’s Federal Information Processing Standards, which has been the dominant referent for “FIPS” in cryptography contexts for decades. ↩

  11. PearDrive project site: https://peardrive.com/. Describes a peer-to-peer file-sharing system and states the software is currently a proof of concept, not yet production-ready, with a beta launch in progress. Retrieved June 2026. ↩ ↩2

  12. Holepunch, Pear Runtime launch announcement, 14 February 2024 (Pears.com): https://pears.com/news/holepunch-unveils-groundbreaking-open-source-peer-to-peer-app-development-platform-pear-runtime/. Open-source peer-to-peer app platform created by Holepunch, described as Tether-backed, with Keet as the first flagship app. ↩ ↩2

  13. PearDrive CLI, GitHub: https://github.com/peardrive/PearDriveCLI. AGPL-3.0, JavaScript; npm package @peardrive/cli; runs on Pear Runtime (pear run). README documents network keys, peer public keys, QR sharing, and archive mode, and flags that network deletion is unimplemented and that file-change syncing will not work as intended until a future PearDriveCore release. Latest release v4.0.0 (29 October 2025). ↩ ↩2 ↩3 ↩4

  14. Tether, Tether, Bitfinex and Hypercore Launch Holepunch: https://tether.io/news/tether-bitfinex-and-hypercore-launch-holepunch-a-platform-for-building-fully-encrypted-peer-to-peer-applications/. Funding provided by Tether and Bitfinex; Paolo Ardoino appointed Chief Strategy Officer; Keet named as the first peer-to-peer application. ↩

  15. PeerDrive: http://peerdrive.org/ and https://github.com/peerdrive/peerdrive. An unrelated, earlier project, an Erlang-based distributed filesystem its authors describe as early alpha. Named here only to prevent conflation with peardrive.com. ↩

  16. Nym Technologies, Nym mixnet and NymVPN: https://nym.com/ and https://nym.com/mixnet. Loopix design documentation: https://nym.com/docs/network/concepts/loopix. FOSDEM 2026 talk, NymVPN: The First Real-World Decentralized Noise-Generating Mixnet for Anonymity: https://fosdem.org/2026/schedule/event/U3UCKS-nym-mixnet/ (Chief Scientist Claudia Diaz, formerly KU Leuven). Source: https://github.com/nymtech/nym and the NymVPN client https://github.com/nymtech/nym-vpn-client. GPL-3, Rust. Two-mode design (5-hop Sphinx mixnet Anonymous Mode; 2-hop AmneziaWG Fast Mode), 24-word access key onboarding, BTC/XMR payment, v2026.4 desktop ad-blocking per March 2026 release notes. ↩

  17. Outline by Jigsaw, project website: https://getoutline.org/. Open-source Shadowsocks-based proxy with Outline Manager for VPS deployment. Source on GitHub: https://github.com/Jigsaw-Code/outline-server. ↩

  18. Amnezia VPN, AmneziaWG 2.0 Is Here, blog post: https://amnezia.org/blog/amneziawg-2-0-available-for-self-hosted. Open-source implementation: https://github.com/amnezia-vpn/amneziawg-go. Documentation: https://docs.amnezia.org/documentation/amnezia-wg/. Technical analysis of v2.0’s transport-layer obfuscation: https://dev.to/bivlked/amneziawg-20-self-host-an-obfuscated-wireguard-vpn-that-bypasses-dpi-4692. Mullvad’s parallel QUIC-obfuscation-for-WireGuard rollout reported by TechRadar via Yahoo, 2026: https://tech.yahoo.com/vpn/articles/mullvad-deploys-quic-obfuscation-wireguard-150302808.html. ↩

  19. Mark Qvist, Reticulum Network Stack, project site: https://reticulum.network/. Reference implementation v1.3.0 released 21 May 2026. Design summary from third-party 2026 coverage: “a single Reticulum network can run simultaneously over LoRa radios + WiFi networks + I2P tunnels + serial connections.” Reticulum manual: https://reticulum.network/manual/Reticulum%20Manual.pdf. ↩

  20. B.A.T.M.A.N. (Better Approach To Mobile Ad-hoc Networking), Freifunk / Open Mesh project: https://www.open-mesh.org/. batman-adv has been part of the mainline Linux kernel since 2.6.38 (2011); current release batman-adv 2025.4 (24 October 2025) per the project version history. Layer-2 routing protocol controlled via batctl; packaged in Debian and Devuan. Developed by the German Freifunk community since 2006 to replace OLSR for large-scale community wireless mesh. Freifunk overview: https://en.wikipedia.org/wiki/Freifunk. ↩

Choosing Document Scanning Tools

  • For: Devuan and Debian users who receive documents from people they do not fully trust.
  • Before you start: a Linux workstation with apt.
  • Reading time: about 16 minutes.
  • You end with: a scanning stack (ClamAV, pdfid, YARA, Firejail) and the reasons for it.

A comparison of the open-source tools that vet PDF, EPUB, and other documents on a Devuan workstation before you open them. Parallel in structure to Choosing encryption tools. Recommends a specific stack at the end; the rest of the document is the reasoning that defends the recommendation.

If you came here from the security overview looking for the input-vetting specialist, the short answer is: ClamAV plus Didier Stevens’ pdfid suite plus YARA, with VirusTotal as an optional consensus check and Firejail as the open-it-safely layer. Read the rest if you want to understand why those and not the alternatives.

TL;DR

Document scanning is the layer between “this PDF arrived” and “I’m now reading it.” It does not prevent attacks; it catches known-bad and flags suspicious-structured documents before you open them. The tools are mostly mature, mostly in Debian and Devuan repositories, and require modest configuration to be useful.

The standard stack: ClamAV for signature matching, Didier Stevens’ pdfid plus pdf-parser for PDF structural triage, YARA for rule-based pattern matching, Firejail as the sandboxed-opening layer. Four tools, each doing one thing well, plus optional VirusTotal hash lookup for multi-engine consensus. Total configuration time on a fresh install: a one-time apt install, a small Didier Stevens download, and an optional YARA-rules clone.

Tools that compete with ClamAV in the open-source AV space (Linux Malware Detect / maldet) lose on coverage and freshness. Tools in the same category as pdfid for PDF analysis (peepdf, mutool) are complementary rather than replacements. Sandboxed-reader alternatives to Firejail (Bubblewrap, Flatpak’s portal model) are usable but require more setup.

What document scanning means and what it doesn’t

Document scanning is a category that sometimes gets confused with antivirus generally and with HIDS specifically. It’s neither. Distinguishing the categories before naming the tools makes the trade-offs clearer.

Structural analysis. Decompose a document into its constituent objects and report on suspicious structural features. For PDFs: embedded JavaScript, launch actions, font-decoder paths historically used for exploits, embedded files. pdfid lists; pdf-parser walks. PDFs only. Latency: seconds. Output: a feature inventory. This is forensic triage, not a verdict.

Signature scanning. Match the file against a database of known-bad hashes and byte patterns. ClamAV is the open-source default. Catches known malware families; weak on novel. Latency: seconds, dominated by signature load. Output: pass or a family name on hit.

Rule-based pattern matching. Write rules that describe malware features (specific byte sequences, structural conditions, magic-number tests, embedded-string tests) and scan files against the ruleset. YARA is the de facto standard. Catches malware families that haven’t been added to AV signatures yet but match documented patterns. Latency: sub-second. Output: list of rules matched.

Multi-engine consensus. Submit a file (or, privacy-preserving, just its hash) to a service that runs many AV engines and aggregate the results. VirusTotal is the public-facing default. Catches what your single AV missed because a different engine caught it. Latency: bounded by network. Output: engine-by-engine verdict plus aggregate counts.

A serious vetting workflow uses three of the four (structural for PDFs, signatures for any format, consensus for second opinion) and treats the fourth (YARA) as optional. They are not redundant. pdfid catches a malicious PDF whose hash isn’t yet in any AV database, by reading its structure. ClamAV catches the file whose name has changed but whose body matches a known sample. VirusTotal catches what your locally-fresh ClamAV missed because a different engine has the signature. The three overlap at the edges but each has a unique core.

Plus the sandbox tier: even with all of the above passing, the safest open is in a Firejail sandbox with no network, so any active content that does execute can’t phone home or reach the filesystem outside its sandbox.

Adjacent categories worth knowing but not the focus here:

  • HIDS (host integrity monitoring). Watches the system for post-compromise changes. Different category: HIDS catches an attack after success; document scanning catches it before success.
  • Endpoint AV in the Windows sense. Continuously scans every file the OS touches. Linux versions exist (ClamAV’s clamd daemon, ClamWin, ESET for Linux) but the Linux threat model rarely justifies the resource cost; on-demand scanning of documents you receive covers most of the realistic threat.
  • Network-level scanning. Mail gateways and proxy filters that scan attachments before they reach the workstation. Out of scope for single-workstation usage; relevant if you operate a mail server or HTTP proxy.

The tool comparison

The tools are organised by the categories above, plus auxiliary inspection tools and the sandboxing layer that sits after the scan.

Structural analysis: PDF

pdfid.py / pdf-parser.py (Didier Stevens)

Public-domain Python scripts by a long-running malware analyst at the SANS Internet Storm Center. Not packaged in Debian or Devuan; requires manual download from didierstevens.com to a script directory of your choosing (typically ~/bin).

Licence: effectively public domain (Didier Stevens releases his work for general use; check the specific release).

What they do: pdfid produces a one-page summary of suspicious-tag counts (/JavaScript, /JS, /OpenAction, /AA, /Launch, /JBIG2Decode, /RichMedia, /EmbeddedFile, /XFA) plus general object counts. pdf-parser walks the PDF object graph and lets you extract specific objects by ID, filter by content, or dump decoded streams.

Strengths:

  • The forensic gold standard for PDF triage. CERTs and DFIR teams reach for these first.
  • Fast (seconds even on large PDFs).
  • Output is small, readable, and stable across versions.
  • Each tool does one thing.

Weaknesses:

  • Not packaged. Manual download means manual update tracking.
  • Single-developer project (Didier Stevens). Stable over twenty years but vulnerable to bus factor.

When to pick: always, for PDF triage. There is no substitute that’s both as accurate and as widely deployed.

peepdf

Active community fork of the original Jose Miguel Esparza project (the original was Python 2 and unmaintained; the active fork is Python 3 and incorporates additional features). Licence: GPLv3.

What it does: interactive PDF analysis. Decodes streams, detects JavaScript, runs some pattern detection against known exploits, lets you navigate the PDF object tree.

Strengths:

  • More featureful than pdf-parser for deep inspection.
  • Interactive shell makes complex walks faster than pdf-parser’s command-line invocation pattern.

Weaknesses:

  • Upstream maintenance is forky and harder to evaluate.
  • More moving parts than pdfid/pdf-parser; more places for bugs.

When to pick: when pdfid plus pdf-parser have flagged a PDF as suspicious and you want to do a deeper interactive walk. Not a replacement for the Stevens tools.

mupdf-tools (mutool)

Part of the MuPDF project (Artifex Software, behind Ghostscript). Packaged in Debian and Devuan as mupdf-tools. Licence: AGPLv3 (commercial alternative available from Artifex).

What it does: structural inspection of PDFs, not security-focused. mutool show file.pdf trailer shows the trailer dictionary; mutool clean -d file.pdf out.pdf rewrites with streams decompressed and visible.

Strengths:

  • Lets you look at what’s actually in a stream object without pdfid’s interpretation layer.
  • Fast, in the repos.
  • Useful for the “is this stream what it claims to be” question.

Weaknesses:

  • Not a security tool. Won’t tell you if a stream is malicious; only what it is.

When to pick: when you’ve already established that a PDF object is suspicious and you want to see its decoded content with no analysis layer in between.

Structural analysis: EPUB

EPUBs are zip archives of XHTML, CSS, JavaScript, and images. There is no dedicated EPUB malware-analysis tool. The standard workflow is extract, inspect, and scan with ClamAV (which recurses into zips automatically).

epubcheck

Java tool from the W3C / DAISY consortium. Packaged in Debian and Devuan as epubcheck. Licence: BSD 3-Clause.

What it does: validates EPUB conformance against the EPUB 2 and EPUB 3 specifications.

Strengths:

  • In the repos.
  • Surfaces malformed EPUBs that often correlate with suspicious origin (legitimate publisher EPUBs almost always pass epubcheck).

Weaknesses:

  • Not a malware tool. Validates structure, doesn’t detect malice.

When to pick: as a heuristic check on EPUBs from non-publisher sources. Run alongside, not instead of, the extract-and-scan workflow.

Signature scanning

ClamAV

The dominant open-source antivirus engine. Originally a small project (Tomasz Kojm, 2001), now developed under Cisco’s Talos security organisation since the 2007 acquisition of Sourcefire. Packaged in Debian and Devuan as clamav (CLI) and clamav-daemon (clamd).

Licence: GPLv2.

What it does: scans files against a signature database for known malware. Recurses into archives (zip, rar, 7z, tar) and document containers (Office, PDF, EPUB) natively. Two main invocation modes: clamscan loads signatures on every invocation (slow); clamdscan queries a running clamd daemon (fast). freshclam updates signatures.

Strengths:

  • In the repos.
  • Native archive recursion (so EPUBs and zipped attachments get scanned automatically).
  • Signature updates are regular and free, no paid subscription.
  • Cisco Talos backing means the project is well-funded and unlikely to disappear.
  • The de facto Linux AV.

Weaknesses:

  • Weak on novel zero-day malware (the limitation of any signature-based approach).
  • clamscan startup is slow (~5-10 seconds for signature load) which adds up over many files; clamdscan fixes this but requires a running daemon.
  • Cisco ownership is a trust consideration for users wary of US-headquartered security vendors. The signature database is open and inspectable, mitigating this for paranoid users willing to audit.

When to pick: always, as the signature-scanning layer. No other open-source option comes close.

Rule-based pattern matching

YARA

Originally a VirusTotal project (Victor Manuel Alvarez, then at VirusTotal, now at Google). Packaged in Debian and Devuan as yara. Licence: BSD-3-Clause.

What it does: pattern-matching engine for malware research. You write rules (specific strings, byte patterns, structural conditions, file-size and magic-number tests); YARA scans files and reports which rules matched.

Strengths:

  • In the repos.
  • Fast (sub-second on most files).
  • The de facto malware-pattern-matching standard; almost every malware report includes YARA rules.
  • Community ruleset at github.com/Yara-Rules/rules covers most known families and is regularly updated.

Weaknesses:

  • The ruleset is what does the work. Without good rules, YARA is just a string-matching engine.
  • Community rulesets are uneven; some rules generate false positives. Pruning is part of operational use.
  • VirusTotal / Google lineage is a trust consideration similar to ClamAV / Cisco. The engine is open and self-contained, mitigating this.

When to pick: when you want to catch novel-but-pattern-matching malware that ClamAV signatures don’t yet flag. Useful supplement to ClamAV, not a replacement.

Multi-engine consensus

VirusTotal

Public service started 2004, acquired by Google 2012, now operated as a Google subsidiary. Free public API with rate limits (4 requests per minute); paid tiers for higher rates and additional features.

What it does: maintains hashes and metadata for billions of files, plus runs roughly 70 AV engines against new submissions. Two interaction modes: upload a file (file is scanned and added to the database, shared with VT’s industry partners); look up a hash (returns the existing record if the file has been seen before, otherwise NotFound).

Strengths:

  • 70-ish engines is broader coverage than any locally-runnable stack.
  • Mature, well-funded, fast.
  • Hash lookup is privacy-safe: the hash reveals which file but not its contents.

Weaknesses:

  • Uploads leak file contents to Google and to VT’s AV-vendor partners. Anything you upload becomes part of an industry-shared database.
  • Google ownership is a trust consideration. The privacy policy permits significant data sharing.
  • Rate limits on the free tier mean it’s not viable as a primary scan layer; treat it as a confirmation step.

When to pick: hash lookup on every file (privacy-safe, fast). Full upload only when you’ve already established suspicion and you want the strongest available second opinion, and only for files whose contents you’re willing to share with the AV industry.

Auxiliary inspection

exiftool

Long-running Perl tool by Phil Harvey (since 2003). Packaged in Debian and Devuan as libimage-exiftool-perl. Licence: GPLv1 or Artistic (Perl’s dual licence).

What it does: reads, writes, and edits metadata in a vast range of file formats. For document analysis: exposes authoring tools, creation timestamps, embedded thumbnails, EXIF and IPTC fields.

Strengths:

  • In the repos.
  • Mature, well-tested, broad format coverage.
  • Useful for provenance triage (does this PDF’s authoring metadata match where it claims to come from?).

Weaknesses:

  • Not a malware tool. Exposes metadata, doesn’t analyse threats.

When to pick: when you want to know who made a file and when, or when you suspect fabricated provenance.

binwalk

Originally created by Craig Heffner; the project’s lineage includes a period of funding from ReFirm Labs (acquired by Microsoft in 2021). Packaged in Debian and Devuan as binwalk. Licence: MIT.

What it does: scans files for embedded files of known formats. Originally built for firmware analysis (finding filesystems and bootloaders inside firmware blobs); useful generally for “is there something hidden inside this thing?”

Strengths:

  • In the repos.
  • Fast, mature, large signature database for embedded formats.

Weaknesses:

  • Not document-focused. Useful for the specific question “is there a payload hidden inside this seemingly-innocent file” but doesn’t analyse the document content itself.
  • Microsoft-adjacent lineage via the ReFirm acquisition is a trust consideration for some users; the project itself remains open-source and community-maintained.

When to pick: when other tools have flagged a document as suspicious and you want to check for embedded content (an executable hidden in an image, a zip appended to a PDF).

Sandboxing for actual opening

Firejail

SUID-based namespace sandbox. Packaged in Debian and Devuan as firejail. Licence: GPLv2.

What it does: runs a process in a namespace sandbox with restricted filesystem access, optional network isolation, seccomp filters, and capability dropping. Profile system covers most common applications.

Strengths:

  • In the repos.
  • Simple command-line usage (firejail --net=none atril doc.pdf).
  • Default profiles for hundreds of applications.
  • Lower friction than VM-level isolation.

Weaknesses:

  • SUID-root binary, which means a sandbox-escape bug becomes a privilege-escalation bug. Firejail has had such bugs in the past; the project’s response has improved but the architectural concern remains.
  • Profiles can be incomplete; a misconfigured profile gives less protection than expected.

When to pick: opening untrusted documents always. The SUID trade-off is acceptable for the use case (the alternative is opening the document without any sandbox, which is worse).

Bubblewrap

User-namespace-based sandbox without SUID. Packaged in Debian and Devuan as bubblewrap. Licence: LGPLv2.

What it does: same general purpose as Firejail (process isolation via Linux namespaces) but without requiring SUID. Used as the sandbox foundation by Flatpak.

Strengths:

  • In the repos.
  • No SUID, so no privilege-escalation risk from sandbox-escape bugs.
  • The Flatpak ecosystem’s sandboxing is built on bubblewrap, which means it’s heavily exercised in production.

Weaknesses:

  • No profile system. You construct each sandbox invocation manually with explicit --ro-bind, --bind, --unshare-* flags.
  • The construction is fiddly enough that most users won’t bother for one-off document opening.

When to pick: if you’ve decided Firejail’s SUID is unacceptable for your threat model and you’re willing to write wrapper scripts. For most users, Firejail’s profile system wins on usability.

Political and lineage clustering

Less politically charged than encryption tools: no Snowden-era controversies to navigate. The tools fall into three lineages, distinguished by funding base and project culture:

Original-author / security-community lineage. Didier Stevens’ pdfid and pdf-parser, exiftool, ClamAV in its early years. Long-running solo or small-team projects with strong individual stewardship. Stable for decades. Vulnerable to bus factor but otherwise low-controversy.

Corporate-FOSS lineage. ClamAV (Cisco-funded since 2007), YARA (Google-funded via VirusTotal), Firejail (community plus occasional corporate contributions), Bubblewrap (Red Hat / Flatpak ecosystem). The corporate funding hasn’t measurably changed project direction or compromised the open-source nature; both projects’ code is inspectable and the funding has stabilised rather than co-opted them. Trust here is mostly an issue for users who object to the parent corporation on principle (US-headquartered, large, surveillance-adjacent for ClamAV / Cisco; advertising-funded for YARA / Google).

Multi-engine consensus with privacy cost. VirusTotal (Google subsidiary). The trade-off is explicit: roughly 70 engines of coverage in exchange for your files entering a Google-owned, AV-industry-shared database. Hash-only lookups avoid the file-disclosure cost; full uploads accept it.

There’s no equivalent here to the VeraCrypt-Microsoft situation in the encryption space or to the systemd-Lennart-Poettering controversies in the init space. The trade-offs are about coverage versus privacy and about project maturity versus complexity, not about competing ideological camps.

How to think about choosing

Three questions, in order:

  1. What documents are you vetting? PDFs only → pdfid plus pdf-parser plus ClamAV. Mixed PDF and EPUB → add EPUB extract-and-grep plus epubcheck. Office documents (Word, Excel, PowerPoint) → add oletools (mentioned below). Academic papers and ebooks → the vanilla stack covers; oletools if you handle Office.

  2. How much configuration time can you absorb? Zero → ClamAV alone with weekly cron of Downloads. Low → add pdfid for the PDFs that actually come through. Medium → the full stack. High (this is your job) → custom YARA rules tuned to your specific threat surface, plus a clamd daemon for fast scans.

  3. What’s your response posture on a hit? If a hit will be ignored or routinely dismissed, none of this matters. The minimum posture: structural-suspicion hit → don’t open without sandbox; signature hit → don’t open at all without inspection; critical hit → don’t open, quarantine, investigate origin. Pre-decide before the first hit, because the first hit will arrive at an inconvenient moment.

The recommendation

ClamAV + Didier Stevens’ pdfid suite + YARA + Firejail. Plus VirusTotal hash lookup if you have an API key (free tier sufficient for casual use).

One-time setup (also done automatically by the script on first invocation):

sudo apt install clamav yara epubcheck firejail file unzip curl git
sudo freshclam
mkdir -p ~/bin && cd ~/bin
wget https://didierstevens.com/files/software/pdf-tools_V0_2_8.zip
unzip pdf-tools_V0_2_8.zip && rm pdf-tools_V0_2_8.zip
chmod +x pdfid.py pdf-parser.py
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
git clone https://github.com/Yara-Rules/rules.git ~/yara-rules

Optional VirusTotal API key (free tier from virustotal.com after account creation):

echo 'export VT_API_KEY="your_key_here"' >> ~/.bashrc

Weekly scan of Downloads via cron (in root’s or your own crontab):

0 6 * * 0 /usr/local/bin/doc-malware-scan.sh -y "$HOME/Downloads" | logger -t doc-scan

After every legitimate apt upgrade that bumped ClamAV, the post-install hook usually runs freshclam; if signatures look stale (older than seven days), run sudo freshclam manually.

What about other document formats

The doc-malware-scan workflow above targets PDF and EPUB explicitly. Adjacent formats are worth knowing about even if they’re not the focus of this project.

Office documents (docx, xlsx, pptx, and the legacy doc, xls, ppt). The Linux user is less likely to encounter these but the threat is real for anyone who does. oletools (apt: python3-oletools) gives Office-format equivalents to pdfid: olevba extracts macros, oledump inspects OLE objects, mraptor flags suspicious VBA patterns. LibreOffice itself will warn before running macros from untrusted documents, but the warning is bypassable by user habituation. Treat Office documents like PDFs: pre-scan with ClamAV plus oletools; if they have macros, open in a VM with no network or don’t open at all.

Archives (zip, rar, 7z, tar, gz). ClamAV recurses into archives natively, which covers the contained files. The additional concern is decompression bombs: small archives that expand to terabytes and exhaust disk or memory. Check uncompressed size before extracting (unzip -l archive.zip, 7z l archive.7z); refuse anything implausibly large. Standard apt-installed tools include the size limits as defaults but the limits aren’t enforced by unzip itself.

Images (jpg, png, webp, svg). Generally low risk on Linux for direct exploitation (image-decoder bugs do exist and have been exploited historically, but Linux image libraries are well-fuzzed). The auxiliary concerns: EXIF metadata can leak location and authoring information (use exiftool to inspect and strip); steganographic payloads can hide data inside images (steghide, stegseek to detect, rarely justified for normal threat models); SVG files are XML and can contain JavaScript (treat SVG like HTML and only open in sandbox).

HTML files. Identical risk profile to opening a web page. Open in a sandboxed browser only; never let your default reader render HTML directly without isolation.

KFX (Amazon’s Kindle format) from non-publisher sources. Harder to inspect without Calibre or Amazon’s own tools; the practical guidance is to convert KFX to EPUB first and then scan, or to read KFX only on the Kindle device itself.

Out of scope, deliberately

Real-time on-access scanning. Continuously scanning every file the OS touches (the Windows-AV model) is possible on Linux via clamd’s on-access mode but rarely worth the resource cost on a single-user workstation. On-demand scanning of documents you actually open covers the realistic threat.

Mail-gateway scanning. Workstation-level. If you operate a mail server, the same tools (ClamAV plus pdfid via amavisd or rspamd) apply at the mail gateway. Different deployment pattern, same engines.

Sandbox-execution detonation (the cuckoo-sandbox model). Run the suspicious document in an instrumented VM, observe what it does. Real category, real value for security researchers, way too much infrastructure for a workstation user. If you need it, REMnux ships pre-configured.

Specialised formats (CAD files, scientific data formats). Format-specific. Most don’t have meaningful malware exposure; the ones that do (DWG, certain GIS formats) have their own niche analysis tools.

QR-code analysis. A QR code is a URL container; the threat is the URL it encodes, not the QR code itself. Scanning the URL with a reputation service (urlscan.io, VirusTotal URL endpoint) handles the actual question. Tooling like zbarimg decodes the QR; deciding whether the decoded URL is hostile is a different category of work than document scanning. Worth knowing about; deliberately not in the doc-malware-scan workflow.

HTML email with tracking pixels. Email tracking pixels (1x1 transparent images served from a tracking domain) leak read-receipts to the sender. Defense is at the mail-client layer (block remote image loading by default; Thunderbird and Geary both support this) rather than the scanning layer. Out of scope here because the question is mail-client configuration, not document scanning.

Downloaded-binary signature verification. Verifying a downloaded binary’s GPG signature or shasum is a separate workflow from scanning a document. The procedure (download .asc plus binary, gpg --verify, or compare sha256sum against the publisher’s published value) is mechanical. The document-scanning workflow can apply afterward (scanning the verified-as-authentic binary for completeness) but the primary defence for binaries is signature verification, not pdfid.

Choosing Supply Chain Tools

  • For: anyone who installs software from language ecosystems (npm, pip, cargo) or from outside the distribution.
  • Before you start: nothing.
  • Reading time: about 13 minutes.
  • You end with: a five-step baseline for installing code without handing it your secrets.

How to defend the code you install. This is the question the other input-vetting work does not answer: Choosing document-scanning tools covers the files you open, this covers the code you install and build. It sits alongside the detection layer (host integrity monitoring, which catches tampering after the fact) and the credential-isolation cross-cutting concern in the master overview (which keeps a compromised build environment from acting as you). The motivation, including the IronWorm npm worm that makes this concrete, is in the security overview. Recommends a baseline at the end; the rest is the reasoning that defends it.

TL;DR

The code you install runs on your machine, often the instant it installs, with your privileges and your secrets within reach. That is a different attack surface from the documents you open, and the document scanner does not touch it.

The baseline, in order of return per minute of effort:

  1. Prefer the distribution. Install from apt main and backports, then Flatpak from Flathub, before reaching into language ecosystems. Fewer trust roots is the cheapest win.
  2. Never pipe curl into a shell, especially as root. Read installers first.
  3. Pin and verify by hash. Commit the lockfile for every project and install from it with hash checking: npm ci, pip install --require-hashes, cargo build --locked.
  4. Contain install-time execution. Set npm to ignore install scripts by default; for ecosystems that cannot disable it (cargo, apt), run the install or build of any code you have not reviewed inside a disposable, network-restricted, credential-free sandbox.
  5. Keep the credentials that publish, sign, or unlock value off the machine that installs and builds. The most common payload is an environment-variable sweep, so the build machine should not hold your cloud, npm, AI, or wallet secrets.

No single one of these stops every attack. Depth is the point, the same as everywhere else in this project. Detection after the fact is host integrity monitoring; recovery is backups, in the overview.

The shape of the threat

Almost every package manager will run code supplied by a dependency at install or build time, before you have done anything deliberate with the package. npm runs lifecycle scripts (preinstall, postinstall). pip runs the build backend of a source distribution. cargo runs build scripts and procedural macros while compiling. apt runs maintainer scripts as root. That install-time execution is the foothold; everything else is what the foothold is used for.

The IronWorm npm worm in June 2026 is the worked example, and it is worth understanding mechanically because it exercised four structural weaknesses at once1:

  • Permissive install hooks. A Rust binary ran from a preinstall script the moment the package installed, before dependency resolution finished, with no build step and no confirmation.
  • Trusted-publishing abuse. It propagated using stolen credentials, including the short-lived OIDC tokens of npm’s Trusted Publishing workflow, so it could publish from a victim’s own pipeline.
  • Forged commit metadata. It planted back-dated commits across nine organisations under the names of trusted automation, dependabot, github-actions, and “claude,” to blend in with routine work.
  • SemVer-tolerant updates. The malicious versions were minor or patch bumps, designed to be picked up automatically by lockfiles configured to accept them.

The result was self-propagating: it stole a developer’s publish credentials, republished itself into that developer’s packages, and infected the next person who installed them. The lesson for defence is that the package manager’s convenience features (run-on-install, automatic version updates, stored publish tokens) are the attack surface, and the defences below are mostly about removing or containing each one.

The ecosystems, one at a time

The ecosystems differ enough that one rule does not fit all. Worst-to-best for your ability to disable install-time execution: cargo (cannot), apt (cannot, and it is root), pip (partly, by preferring wheels), npm (yes, with a flag and a cost), Flatpak (sandboxed regardless).

apt and Devuan repositories

The Debian and Devuan archives are cryptographically signed, and apt verifies the release signature against trusted keys before installing anything. The main archive is not the threat. The threat is what you add to it and what you run around it.

apt runs maintainer scripts (preinst, postinst) as root at install time, so a compromised package or repository is a root-level install-time execution problem, the highest-privilege version of the npm hook issue. You cannot easily sandbox dpkg, so the defence is trusting the source rather than containing it.

What to do:

  • Minimise third-party repositories. Prefer Devuan and Debian main and backports, then Flatpak, over downloading upstream .deb files or adding vendor repositories.
  • When you must add a repository, install its signing key to /etc/apt/keyrings/ and bind the repository to that key with a Signed-By: line in the .sources entry, so a different key cannot silently sign updates.
  • Never pipe curl into sh, and never as root. These installers bypass the package manager, its signatures, and its uninstall path entirely. Download, read, then run if you trust it.
  • apt-listchanges and apt-listbugs surface what an upgrade is about to do before it does it.

Flatpak

Flatpak is the one ecosystem here whose runtime model helps you. Apps run in a bubblewrap sandbox with access mediated by portals, so a malicious or compromised app is constrained by what it was granted. The weakness is the grant: an app with broad static permissions (filesystem=host, broad device access, or the ability to talk to the Flatpak control interface) has escaped the sandbox in practice.

What to do:

  • Prefer Flathub “verified” apps, which are published by the upstream project rather than a third party.
  • Inspect permissions before installing: flatpak info --show-permissions <app>.
  • Tighten over-broad permissions with flatpak override or the Flatseal GUI. Never leave filesystem=host on an app that does not need it.
  • Treat broad device access or session-bus access as a red flag worth understanding before granting.

npm

npm gives you the most direct control over install-time execution, at a cost.

Contain install hooks. Disable lifecycle scripts globally and re-enable per project only when a package genuinely needs a native build:

npm config set ignore-scripts true

For a single install in CI or on a project you do not fully trust:

npm ci --ignore-scripts

The mitigation that Microsoft recommended after the March 2026 Axios compromise was exactly this2. Two honest caveats. First, the January 2026 “PackageGate” disclosure found zero-days in npm, pnpm, vlt, and Bun that undermined lifecycle-script disabling itself, so this is a layer, not a wall3. Second, some legitimate packages need their scripts to build native code, so blanket disabling has a real cost and you will re-enable selectively.

Pin and verify. Commit package-lock.json and install from it with npm ci, which installs exactly the locked versions and verifies each package against the integrity hash in the lockfile. Do not run npm install in CI, which can drift.

Detect downgraded trust. pnpm 10.21 and later ship a trustPolicy setting whose no-downgrade mode refuses to install a package whose publish-time trust level has dropped, for example a package that used to ship with provenance and now does not, which is an early signal of a compromised account4.

Screen before you add. Socket and npq screen packages for suspicious behaviour before installation. Check maintainer history, download counts, and repository activity, and be especially careful with package names suggested by an AI assistant, which can hallucinate a name that an attacker has since registered.

Hardening what you publish is in the cross-cutting section below.

pip and PyPI

pip’s install-time execution depends on the artifact. A source distribution runs its build backend (historically setup.py) at install; a wheel does not run arbitrary install code, though it still executes on import. Prefer wheels where you can (--only-binary :all: for the dependencies that ship them).

Pin and verify by hash. Hash-checking mode forces every dependency, including transitive ones, to be pinned with a verified hash:

pip install --require-hashes -r requirements.txt

Generate the hashed lock file with uv or pip-tools rather than by hand5:

uv pip compile requirements.in --generate-hashes -o requirements.txt
# or, with pip-tools:
pip-compile --generate-hashes requirements.in

Audit. Run pip-audit in CI to catch dependencies with known advisories before they ship.

Dependency confusion. If you use private packages, pin the index and use a resolver strategy that will not silently pull a public package that happens to share an internal name; uv’s first-match index strategy is built for this.

Publishing hardening is in the cross-cutting section.

cargo and crates.io

cargo is the hard case, because by design it executes arbitrary code at build time through build scripts (build.rs) and procedural macros6. There is no clean equivalent of --ignore-scripts, because the code runs as part of compilation rather than as an optional hook.

It is worse than that. The RustSec maintainer’s guidance is to not run any cargo command on a project you have not reviewed, because every cargo command invokes Cargo, which can be made to execute arbitrary code, and that includes the supply-chain tools themselves: cargo audit, cargo deny, and cargo vet all go through Cargo and can be turned into code execution, for instance via a .cargo/config.toml alias in the repository7. The practical consequence is that the auditing tools are not a safe way to inspect untrusted code; they are a way to check code you already intend to build.

So for cargo the defence is review and sandboxing, not flags:

  • Commit Cargo.lock and build with cargo build --locked (or --frozen offline), which pins exact versions and verifies registry checksums.
  • Run the build, and any cargo command, on code you have not reviewed inside the same disposable, network-restricted sandbox you would use for an untrusted install.
  • Inside that trusted-build context, the RustSec tooling earns its place: cargo audit and cargo deny (with the cargo-deny-action in CI) flag advisories, and let you also gate licences and crate sources8. cargo-vet and cargo-crev record human audit attestations. cargo-supply-chain lists who you are trusting. cargo-auditable embeds the dependency tree into the compiled binary so the result stays auditable.

Tarballs and source you build yourself

The same principle applies to a tarball or a git clone you build with make: the build runs whatever the Makefile and configure scripts say, with your privileges. Verify the download against a signature or a published checksum from the project (not from the same place you got the file), read the build files if the source is unfamiliar, and build untrusted source in a sandbox.

Cross-cutting defences

These apply across every ecosystem above.

Pin and verify by hash

A lockfile that records exact versions and integrity hashes, committed to the repository and verified on every install, is the baseline that turns “install whatever resolves today” into “install exactly what was reviewed.” npm ci, pip install --require-hashes, and cargo build --locked are the per-ecosystem forms. Lockfiles do not fully cover git-based dependencies, so pin those to a commit, not a branch.

Disable or sandbox install-time execution

Where the ecosystem lets you disable run-on-install (npm), do it by default. Where it does not (cargo, apt, source builds), contain it instead: run the install or build of any code you have not personally reviewed inside a disposable environment that has no network it does not need and none of your credentials. A throwaway container, a VM, or Firejail per the hardening doc (Devuan secure workstation) all work; Qubes makes it the default. This is the single highest-value habit in this document, because it holds regardless of ecosystem and regardless of whether a specific control was bypassed: install-time code that runs in an empty, network-restricted sandbox cannot sweep secrets that are not there.

Keep publishing and signing credentials off the build machine

This is the credential-isolation cross-cutting concern in the overview, applied to packaging. The machine that installs and builds should not also hold the credentials that publish packages, sign releases, or unlock funds, so that a compromised build environment cannot act as you.

For publishing specifically:

  • Use OIDC trusted publishing instead of a stored long-lived token. PyPI Trusted Publishers exchange a short-lived OIDC token for upload rights, so there is no PYPI_PUBLISH_PASSWORD secret to steal9; npm’s Trusted Publishing does the same and additionally produces provenance attestations.
  • If you must use a token, scope it narrowly and rotate it.
  • Treat the CI pipeline as part of the supply chain: pin GitHub Actions to commit SHAs rather than tags, start workflows with minimal permissions, and require manual approval for release workflows. OIDC is not a silver bullet, because a compromised workflow file or a malicious action can trigger a legitimate token exchange and publish malicious packages, which is how the LiteLLM and GhostAction incidents worked10.
  • Protect the registry account with WebAuthn-based 2FA.

Secret hygiene on the machine that installs

Because the most common payload is an environment-variable and credential-file sweep, the machine that runs npm install, pip install, or cargo build should not have your AWS, GCP, Vault, npm, Anthropic, OpenAI, or wallet secrets sitting in environment variables, shell rc files, or unencrypted dotfiles. Keep them in a secrets manager that releases them per process, or on a separate user or VM, so an install-time sweep finds nothing. A meaningful crypto seed should never be on the build machine at all; cold storage on an air-gapped machine is the credential-isolation pattern for funds.

Screen before you add, but know the limits

Screen new dependencies with the per-ecosystem scanners: Socket or npq for npm, pip-audit or osv-scanner for pip, cargo audit or cargo deny (sandboxed) for cargo. Check the human signals too: maintainer history, repository activity, and whether the name is one an AI assistant suggested and an attacker may have registered. The limits are real: these tools catch known-bad and some heuristic-bad, not a novel implant in an otherwise-trusted package, and as noted the cargo plugins are unsafe to point at untrusted code.

Recommendation

For a single-user Devuan workstation, in order:

  1. Prefer apt main and backports, then Flathub verified apps, before language ecosystems.
  2. Never pipe curl into a shell; read installers first.
  3. For Flatpak, review and tighten permissions with Flatseal; no filesystem=host on apps that do not need it.
  4. For every language project, commit the lockfile and install from it with hash verification (npm ci, pip install --require-hashes, cargo build --locked).
  5. Set npm to ignore install scripts by default; re-enable per project only when a package needs a native build.
  6. Run the install or build of any dependency set you have not reviewed inside a disposable, network-restricted, credential-free sandbox.
  7. Keep cloud, npm, AI, and wallet credentials off the machine that installs, per the credential-isolation pattern.

Add if you publish packages: OIDC trusted publishing with no stored tokens, WebAuthn 2FA on the registry account, provenance attestations, and GitHub Actions pinned to commit SHAs with minimal permissions and manual release approval.

Add if you hold meaningful crypto: the seed never lives on the build machine; cold storage on an air-gapped machine.

The axis driving this order is security first, then the long term, per the project defaults. Item 6 is the load-bearing one: sandboxing the build of unreviewed code is the control that still works when ignore-scripts is bypassed, when a stolen OIDC token publishes a bad version, or when the registry serves a swapped release. The rest raise the cost of an attack; item 6 contains the blast radius when one gets through.

The honest limits

No single control stops every supply-chain attack, and the marketing around each one oversells it. Trusted publishing does not help if your package manager runs preinstall scripts from every dependency, and blocking those scripts does not help if an attacker replaces a version at the registry level. PackageGate showed that the lifecycle-script-disabling defence itself had bypasses. OIDC eliminates the stored token but not the compromised workflow that mints a legitimate one. Lockfiles do not fully cover git dependencies. cargo cannot disable build-time execution at all, which is why review and sandboxing carry the weight there.

These defences shift the odds and contain the damage; they do not guarantee safety. Detecting a compromise after it lands is a different layer (host integrity monitoring), and coming back from one is backups (the overview). Depth across all of these is the posture, not faith in any one of them.


  1. JFrog Security Research, “IronWorm: Shai-Hulud’s rustier cousin,” 3 June 2026, https://research.jfrog.com/post/iron-worm-shai-hulud-rustier-cousin/. Corroborated by BleepingComputer, “New IronWorm malware hits 36 packages in npm supply-chain attack,” https://www.bleepingcomputer.com/news/security/new-ironworm-malware-hits-36-packages-in-npm-supply-chain-attack/. Disclosed via the compromised asteroiddao npm account in the Arweave/WeaveDB ecosystem; reported package count ranges from 36 to 43; 86 environment variables and 20 credential-file paths targeted; 57 back-dated commits across nine organizations under automation identities including dependabot, github-actions, and “claude.” ↩

  2. Microsoft Security Blog, “Mitigating the Axios npm supply chain compromise,” 1 April 2026, https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/. Recommends npm ci --ignore-scripts or npm config set ignore-scripts true, and adopting OIDC trusted publishing to eliminate stored credentials. ↩

  3. Bastion, “npm Supply Chain Attacks 2026: Defense Guide,” 17 February 2026, https://bastion.tech/blog/npm-supply-chain-attacks-2026-saas-security-guide, reporting the January 2026 “PackageGate” zero-days (disclosed by Koi) affecting npm, pnpm, vlt, and Bun that undermined lifecycle-script disabling. ↩

  4. pnpm documentation, “Mitigating supply chain attacks,” https://pnpm.io/supply-chain-security. The trustPolicy setting with no-downgrade blocks installation of a package whose publish-time trust level has decreased. ↩

  5. pip documentation, hash-checking mode, https://pip.pypa.io/en/stable/cli/pip_hash/ and --require-hashes; uv and pip-tools --generate-hashes for producing the hashed lock file, per “Defense in Depth: A Practical Guide to Python Supply Chain Security,” bernat.tech, 10 March 2026, https://bernat.tech/posts/securing-python-supply-chain/. ↩

  6. Rust Security Response WG advisories for the Cargo package, https://advisories.gitlab.com/pkg/cargo/cargo: “by design Cargo allows code execution at build time.” ↩

  7. Sergey Davidoff, “Do not run any Cargo commands on untrusted projects,” https://shnatsel.medium.com/do-not-run-any-cargo-commands-on-untrusted-projects-4c31c89a78d6. Any command starting with cargo can execute arbitrary code, including the cargo-audit, cargo-deny, and cargo-vet plugins, because they invoke Cargo, which can be redirected via .cargo/config.toml. ↩

  8. RustSec Advisory Database, https://rustsec.org/. cargo audit scans Cargo.lock against the database; cargo deny (with cargo-deny-action) adds advisory, license, and source policy enforcement; cargo-auditable embeds the dependency tree into compiled binaries. ↩

  9. Python Packaging Authority, “Publishing to PyPI with a Trusted Publisher,” https://docs.pypi.org/trusted-publishers/. OIDC short-lived identity tokens replace stored API tokens for uploads. ↩

  10. PyPI security best practices and the LiteLLM and GhostAction incidents, per https://github.com/lirantal/pypi-security-best-practices and the BerriAI/litellm Trusted Publishers migration issue, https://github.com/BerriAI/litellm/issues/24542: a compromised CI/CD pipeline can publish malicious versions even with trusted publishing enabled, so pin Actions to commit SHAs, use minimal permissions, and require manual release approval. ↩

Choosing Hardware for Linux in 2026

  • For: people buying a machine to run Linux on. If you already own a working PC that isn’t a Mac, install Linux on it and skip this.
  • Before you start: Choosing the OS.
  • Reading time: about 22 minutes.
  • You end with: a tier (a desktop from parts, a laptop, or a phone with a keyboard) and specific models within it.

What to put Linux on. Three sovereignty tiers (desktop you built, Linux-friendly laptop, phone with a keyboard) with a clear nudge away from Macs in any tier. Companion to the OS guide; updated as things change.

TL;DR

If you already own a working PC that isn’t a Mac, install Linux on it and stop reading. This document is for the case where you are actually buying.

Three tiers, ordered by sovereignty and by my recommendation strength:

  1. A desktop you built from parts. Most sovereign hardware available at consumer prices. AMD Ryzen, AMD Radeon GPU, Intel Wi-Fi card, ECC RAM if the budget allows. No vendor in the firmware chain you didn’t put there yourself. The boring-correct pick if you have a desk.
  2. Framework 13/16, current ThinkPad (X1 Carbon Gen 14, T14 Gen 7, T16 Gen 5), or a refurbished X230/T480 with Libreboot. The practical sovereign-laptop tier. Framework for modularity (swap the mainboard to upgrade the CPU generation). Current ThinkPad T-series now matches Framework on iFixit’s repairability score (10/10 at MWC 2026)1 while remaining the keyboard and battery-life champion. Refurbished X230/T480 if you want open boot firmware and don’t mind a 2012/2018 machine.
  3. Pixel 8 or newer plus USB-C dock plus external display, running GrapheneOS with its experimental desktop mode. Android 16’s polished Desktop Mode (shipped with the March 2026 Pixel Feature Drop as part of QPR3, built on Samsung DeX’s foundations per Google’s I/O 2025 announcement)2 is on stock Pixel today; GrapheneOS users get experimental desktop mode usable now with the same hardware path, and the stable version is expected via Android 17 release uplift3. PostmarketOS on a Pixel, plus the dedicated Linux phones (PinePhone Pro, Librem 5), are more radical paths and are not daily-driver-ready in 2026.

Do not buy a MacBook to install Linux on. Asahi Linux work covers M1/M2 well, but M3 boots without a working GPU, M4 development stalled after Apple’s architecture changes broke the project’s reverse-engineering tooling, M5 bring-up just started, and the project founder left in early 2025 over kernel-upstreaming politics4 5. You’d be paying Apple’s hardware margin for a machine optimised for an OS you intend to replace, then chasing a target Apple’s silicon team does not want you to hit.

Do not buy a Mac for general computing if sovereignty is the question this document is trying to answer. Choosing the OS covers when keeping a Mac for one specialised workflow (video, audio, Adobe) is a reasonable concession; that concession does not extend to “buy a new Mac for general computing.”

How this fits with the OS guide

Choosing the OS is upstream of this one. Pick a distro first; pick hardware to match. Most readers already have a working PC and the question “what to put Linux on” is answered by “the machine you already own, unless it’s a Mac.” This document is for the case where you are actually buying.

The sovereignty axis used here is the same one the OS guide uses: the question is not “which vendor is best” but “which vendor is in the conversation forever, and on what terms.” Apple expects to be in the conversation forever and dictates the terms. Microsoft is increasingly putting itself in the conversation through firmware (Pluton’s integration into AMD Ryzen 6000+ and selected Intel chips, pushed through Windows-certified OEM hardware). Google, on Pixel hardware, sells you the device and then leaves the software conversation entirely if you install GrapheneOS. The vendors who sell explicitly for Linux (Framework, System76, Tuxedo, Star Labs, Purism) sit lower on the vendor-presence axis. The desktop you built yourself, from commodity parts, sits lowest.

Tier 1: A desktop you built from parts

A self-assembled desktop is the most sovereign general-purpose computer you can own in 2026 short of exotica. Every component is commoditised; you can replace any single piece without replacing the rest; nothing on the board expects an OEM telemetry pipeline.

CPU. AMD Ryzen (7000-series Zen 4, 9000-series Zen 5) over Intel for Linux in 2026. AMD’s PSP (Platform Security Processor) is no better than Intel’s ME at the firmware-blob layer (both are closed-source coprocessors with their own privileged execution environments), so don’t pick AMD for that reason. Pick it because the kernel-side support story is cleaner: Ryzen’s Linux performance, idle power, and scheduler interactions track upstream tightly, while Intel’s E-core/P-core hybrid scheduling has had multiple regressions on mainline kernels. AMD Ryzen also makes ECC RAM accessible at consumer prices (covered below).

GPU. AMD Radeon over Nvidia for any machine that runs a Linux desktop. The open-source amdgpu driver is in mainline, Wayland-native, handles HDR/VRR/FreeSync, suspends and resumes correctly, and has zero out-of-tree dependencies. Nvidia’s situation has improved (GSP firmware lets the open nouveau driver work for non-gaming use, and Nvidia’s proprietary driver has gained Wayland support), but you are still chasing a moving target where the open and closed drivers behave differently, Wayland sessions have subtle issues, and suspend remains unreliable. Exception: if you do ML/CUDA work, Nvidia remains the practical pick despite the friction, and a second small AMD machine becomes the general-computing answer.

Wi-Fi and Bluetooth. Intel AX2xx-series cards (AX210, AX211, BE200) work mainline-out-of-box with nothing beyond linux-firmware. MediaTek MT7921/MT7922 are the second pick. Avoid Realtek and Broadcom: driver support ranges from “binary blob from the vendor that breaks every kernel” to “no support at all.” On a desktop you can always swap the card; a single bad pick is not catastrophic.

RAM. ECC if the budget allows. The argument is not theoretical cosmic-ray bit-flips; the argument is that ECC modules are paired with more conservative validation and chip binning, so they fail less often even when ECC correction is not actively kicking in. AMD Ryzen plus an ASRock/ASUS Pro motherboard is the cheap path to ECC at consumer prices; on Intel you’re paying Xeon tax for the same feature.

Storage. NVMe over SATA. Boring brand names with mature Linux support (Samsung 9xx Pro, WD SN850X, Crucial T700) over budget controllers with intermittent NVMe-disconnect issues under heavy I/O.

Case, PSU, motherboard. Boring. Fractal, Corsair, BeQuiet, Seasonic. The boring choice is the right choice.

What this tier dominates. No proprietary EC firmware running parallel to the OS. No fingerprint reader or webcam talking to a Windows-only driver you’ll have to fight. Every component is replaceable. The OS install is the only software that touches the hardware. That’s the whole pitch.

What this tier costs you: portability. If you cannot accept a desktop in your home or office, skip to Tier 2.

Tier 2: Laptops

In rough sovereignty order. One note before the picks: the “Linux-certified” Dell XPS / HP / Lenovo Linux SKUs from major OEMs exist, the certification is real, the hardware works, but you’re still buying from a vendor whose primary business is shipping Windows machines with vendor telemetry baked into the firmware, and the Linux SKU is a side product. The dedicated-Linux vendors below are competitive on price and better aligned on incentives.

Framework: primary recommendation

Framework 13 and Framework 16 are the most modular mainstream x86 laptops available in 2026. The motherboard is socketed; you can swap CPU generations without buying a new laptop. The ports are modular expansion cards; you choose USB-A/USB-C/HDMI/DisplayPort/MicroSD/storage per slot. Every internal part is sold individually with public service manuals. The April 2026 Panther Lake release shipped with explicit Linux support, including Fedora and Ubuntu OEM images.

What it does well: Linux compatibility is excellent and vendor-tested before release. Battery life on the current AMD Ryzen Framework 13 is competitive with current ThinkPads. The 16“ model accepts a discrete GPU expansion bay if you want gaming or ML on a portable.

What it does less well: per-unit cost is higher than a comparable ThinkPad. The hinge on the earliest Framework 13 generations was a weak point (fixed in current units; check before buying refurbished). The 16“ model is bulky for its screen size. The keyboard is good, not great: the ThinkPad X1 Carbon remains better.

On repairability scoring: iFixit gave the current Lenovo T14 Gen 7 and T16 Gen 5 the same 10/10 rating Framework has held for years1. iFixit measures whether parts are accessible and replaceable. It does not measure whether you can swap the mainboard for a CPU generation upgrade or whether the ports themselves are user-configurable. Framework still wins on those two dimensions; the generic “most repairable” claim now has competition.

The political flag: GNOME-side commentators have called Framework everything from “supports Fascist and Racist s***heads” (GNOME OS Team, October 2025) to “Nazibook 13 pro” (GNOME contributor Jordan Petridis, April 2026, on Mastodon), after Framework’s continued partnership with DHH (David Heinemeier Hansson) on his Omarchy distribution and its sponsorship of Hyprland. The OS guide treats this pattern as a community-politics flag for GNOME, not for Framework; from the capture-risk frame the same pattern reads as inadvertent endorsement of Framework, since the actors attacking it are the ones most willing to weaponise CoC enforcement against engineering work they oppose politically. See GNOME’s political turn and the Code of Conduct asymmetry for the full chronology and primary sources.

Current ThinkPad T-series and X-series

The 2026 generation refresh landed at MWC 2026 in early March: ThinkPad X1 Carbon Gen 14 (Panther Lake)6, T14 Gen 7, T16 Gen 5, and T14s Gen 71. T14 Gen 7 and T16 Gen 5 both ship with Intel Core Ultra Series 3 (vPro) or AMD Ryzen AI Pro 400 Series options, larger speakers, an optional 5MP camera, and iFixit’s 10/10 repairability rating. Lenovo officially supports Linux on most current ThinkPad SKUs (Ubuntu and Fedora are tested upstream). The keyboard is the best in the laptop industry. Fingerprint readers, webcams, and Thunderbolt work mainline on most current generations.

The flag worth naming: Lenovo is Chinese-owned (Beijing-headquartered, listed on the Hong Kong exchange), and the supply-chain argument that applies to any vendor with a national-government overlay applies here. For a single-machine threat model where state-level interest in your hardware is implausible, this is moot. For a higher-stakes threat model, it is one of the inputs.

Refurbished ThinkPad X230 or T480 with Libreboot

The sovereignty-maximalist laptop path. Both the X230 (2012, Ivy Bridge) and the T480 (2018, Coffee Lake) have full Libreboot ports as of recent releases:

  • X230 has been a Libreboot staple for years.
  • T480/T480s support was implemented by Mate Kukri and landed in Libreboot 20241206 (December 2024)7; Libreboot 26.01 (30 January 2026) refined it further with headphone-jack detection fixes8. Thunderbolt support landed in 26.01 RC1 but was pulled before the stable release after S3-resume regressions on some units; expect that to return in a later release.

Both machines use me_cleaner to neuter Intel ME to the legal minimum; the X230 with Libreboot additionally uses deguard to handle ME configuration, and the T480 port uses the same deguard rewrite that now supports both boards.

What you get: a laptop whose boot firmware is open, auditable, and built from source you can read.

What you give up: it’s a 2012 or 2018 laptop with the hardware ceilings of those years. On the X230, the original BIOS whitelists which Wi-Fi cards will boot; Libreboot removes the whitelist, so you can drop in a current AX210 card. The T480 has no Wi-Fi whitelist to begin with. Batteries on units this old will need replacement; community vendors still sell fresh cells.

Pick this if your threat model specifically includes “the firmware on the laptop matters and I want to verify it.” For most readers, the current ThinkPad above does the same job at much higher performance for similar money.

System76

US-based hardware company in Denver, ships Pop!_OS preinstalled, runs system76-firmware on most current models, funds the COSMIC desktop. The hardware itself is rebranded Clevo/Sager whitebooks (true of most boutique Linux vendors), but the firmware customisation is real.

What it does well: ships with Linux configured correctly, the company is actively in the Linux conversation, NVIDIA driver integration is the smoothest of any vendor.

What it does less well: Pop!_OS is Ubuntu-based and the OS guide currently recommends against a fresh Pop!_OS install while COSMIC is alpha/beta. The hardware ranges from “good” to “fine” but doesn’t reach Framework’s modularity or ThinkPad’s keyboard quality.

Tuxedo Computers and Star Labs

Tuxedo Computers (German) and Star Labs (British) ship Linux preinstalled. Tuxedo offers the broader range, including AMD configurations. Star Labs makes a smaller line (StarBook, StarLite, StarFighter), and has done public work on opening their firmware (partial coreboot ports on some models). Both are smaller companies; warranty and resale logistics favour EU buyers.

Pick if: you’re in the EU and want a vendor in your jurisdiction, or you specifically value Star Labs’ open-firmware work.

Purism Librem 14

The most libre laptop available, sold by the most politically explicit vendor. PureBoot firmware (Coreboot plus Heads), hardware kill switches for camera/microphone and Wi-Fi/Bluetooth, designed around the FSF’s free-as-in-freedom criteria.

What it does well: the political posture you’re paying for is real, not marketing; the kill switches break the circuit physically, the firmware is auditable, PureBoot uses a YubiKey for tamper-evident attestation.

What it does less well: performance is a generation behind, the company has had fulfilment delays (the Librem 5 phone took years to ship after the campaign closed), and the per-unit price is high relative to specs.

Pick this if sovereignty matters to you to the point that “every component must be replaceable and every blob removable” is the floor.

NitroPad (Heads-flashed ThinkPad from Nitrokey)

The pre-flashed-with-Heads option. Nitrokey (German, open-firmware hardware-token vendor) sells refurbished ThinkPads (currently the X230, T430, and X1 Carbon) with Heads coreboot pre-installed and a Nitrokey USB token paired to the laptop for verified-boot attestation. The pairing is the value-add: at each boot, Heads measures the boot chain, computes an HMAC, and lights a green-or-red LED on the paired Nitrokey indicating whether the measurement matches what was last enrolled. Tamper produces a red LED before you type your passphrase.

What it does well: gets you a working Heads/coreboot machine without flashing it yourself. Flashing Heads requires SOIC clips, an external programmer (Raspberry Pi or similar), and the time to follow the (long) procedure correctly the first time. NitroPad pays for that work plus the QA. Comes with the verified-boot-token integration ready to use.

What it does less well: you’re paying for a used ThinkPad plus Heads-flashing-as-a-service. The hardware is old (xx30 generation is 2012-2013); current Heads-compatible hardware doesn’t include modern chips. Indian shipping plus import duty roughly doubles the EU street price.

Pick this if you want Heads/coreboot working today, don’t want to do the flashing yourself, and the X230/T430/X1 Carbon performance envelope is enough for your work. Order at nitrokey.com/nitropad.

Dasharo-supported hardware (the modern coreboot frontier)

For users who want coreboot on current-generation hardware rather than xx30-generation ThinkPads, Dasharo (Polish, 3mdeb) is the active modern coreboot distribution. Supported hardware as of 2026: NovaCustom laptops (NV4x, NV41 series), MSI desktop boards (the PRO Z690-A series and selected newer models), Protectli vault hardware, several Raptor Computing boards.

Dasharo is positioned differently from Heads: Heads is a Coreboot payload focused on tamper-detection-via-TPM-attestation; Dasharo is a coreboot distribution-with-firmware-stack that may or may not use Heads as a payload. Dasharo-with-Heads is supported on some boards (the NovaCustom NV4x lineup specifically). For users who want the “open firmware on current hardware” property, Dasharo is the only meaningful answer; Purism Librem and System76 are the alternatives but more vertically integrated (you buy from them or you don’t).

Pick Dasharo-supported hardware if you’re at Tier 1 (built-it-yourself desktop) and want coreboot from a vendor whose only business is the firmware. Documentation at dasharo.com.

The MacBook trap

Don’t buy a MacBook to install Linux on. Asahi Linux’s situation in 2026 is roughly:

  • M1 and M2 are well supported via Fedora Asahi Remix 43, which shipped 18 March 2026 with 120Hz display and M2 Pro/Max microphone fixes. Apple Silicon Mac Pro support landed in this release9.
  • M3 boots but the GPU does not work and KDE runs in software rendering (LLVMpipe). One driver developer in January 2026 described the state as “ONLY the internal SSD, display, keyboard, and trackpad work”10.
  • M4 development has stalled. Apple’s M4 architecture changes broke the project’s reverse-engineering tooling (the SPTM-at-GL2 plus MMU-enabled-EL2 changes that Sven Peter described as “rather painful”). A Linux 6.17 regression set the schedule back further4.
  • M5 bring-up is in its earliest phase; no public confirmation of basic Linux boot as of early 20264.
  • Hector Martin, the project founder, left in early 2025 over kernel-upstreaming politics, specifically the Rust-in-kernel argument and the difficulty of getting 1000+ downstream patches accepted upstream. The seven-person successor team is focused on upstreaming, not new hardware5.

Translated: buying a MacBook with the intention of running Linux on it means either buying an M1/M2 used machine where the work is mostly done (the realistic path) or buying an M3+ machine where you’re chasing a target Apple is actively making harder to hit. Either way, you’ve paid Apple’s hardware margin for a machine optimised for an OS you’re going to replace. Buy a Framework or ThinkPad. They’re cheaper, modular, and supported on every distro the OS guide names.

Tier 3: A phone with a keyboard

The provocative tier. PostmarketOS sits at the OS layer; the Pixel and the dedicated Linux phones sit at the hardware layer. The rungs below are organised by hardware first, with OS options inside each.

Pixel 8 or newer: the practical hardware

Pixel 8/8a/9/9a/9 Pro/10/10 Pro/10a all support DisplayPort Alt Mode over USB-C; everything before Pixel 8 lacks the hardware. The Pixel 9a at roughly ₹38,000 is the value pick.

Hardware setup, common to both OS options below:

  • USB-C hub with DisplayPort Alt Mode and HDMI out plus PD passthrough for charging. Anker, Plugable, and UGreen sell working models. Cheap charge-only USB-C cables do not pass video; look for “DisplayPort Alt Mode” or “USB-C 4K” on the spec sheet11.
  • External monitor: 1080p or 1440p in practice; the Pixel can negotiate 4K 60Hz on some configurations but most setups land at 1920x1080 or 2560x1440 today.
  • Bluetooth keyboard and mouse, or a USB keyboard/mouse via the hub.

Option A: GrapheneOS with experimental desktop mode (practical)

The stable polished Android 16 Desktop Mode (QPR3 baseline, taskbar, freeform windows, multi-monitor support) shipped on stock Pixel with the March 2026 Pixel Feature Drop, built on Samsung DeX’s foundations per Google’s I/O 2025 confirmation2 12. GrapheneOS users do not get that polished build immediately: Google’s 2026 AOSP cadence means QPR3 features are not flowing downstream to custom AOSP forks the way they used to, and the stable Desktop Mode on GrapheneOS is widely expected to arrive via the Android 17 release uplift3. In the meantime, GrapheneOS ships an experimental desktop mode that is usable today with the same USB-C dock plus keyboard plus mouse hardware path3. It works; it’s not as polished as stock QPR3.

What you get: a real desktop with a taskbar, resizable windowed apps, and the phone screen functioning independently; calls and messages still arrive on the phone display while the desktop session runs on the monitor. Browser with many tabs works. Google Docs, Gmail, Lightroom, and spreadsheets work. Terminal-via-Termux works.

What you don’t get: heavy creative work, gaming, or anything that wants discrete GPU power. Some Android apps adapt cleanly to windowed mouse-driven use; some are barely usable in window mode. The trackpad-emulation story is still rough.

The Termux companion: install Termux from F-Droid (the Google Play build is deprecated and stale; F-Droid is the live channel), pkg install openssh tmux mosh, ssh out to a real machine, and run your real work there. This combination (Pixel plus GrapheneOS plus Termux plus mosh to a remote desktop or cloud machine) is, for some workflows, a complete substitute for a laptop. Code review, writing, server administration, light scripting, and anything browser-centric all fit comfortably.

This setup is sovereignty-maximal in a specific way: you carry one device that is also your phone; the OS on that device (GrapheneOS) is one of the most hardened consumer OSes shipping; and your actual work lives on a machine you control somewhere else. Loss or seizure of the device costs you the device, not the work.

Option B: PostmarketOS on the same Pixel (more radical)

PostmarketOS is Alpine Linux for phones. The current stable release is v25.12 (December 2025); v26.06 is in development13. As of February 2026, PostmarketOS supports an estimated 723 device models, including the Pixel 3a, OnePlus 6T, Fairphone 4/5, PinePhone, PinePhone Pro, and a long tail of older hardware14. The strategic shift in 2026 is toward generic mainline kernels (one kernel image bootable across many devices via Device Tree overlays), replacing the per-device kernel-fork model that previously throttled the project’s pace14.

What it gives you: real Linux on phone hardware. GNOME Mobile, Phosh, Plasma Mobile, or Sxmo as the desktop. No Android base, no Play Services, no advertising identifier, no vendor analytics. Alpine’s apk package manager.

What it costs you: app compatibility. Most Android apps run via Waydroid (a container) with caveats; banking apps that require device attestation typically do not work, even with microG. Camera support is uneven (the camera-stack on many mobile SoCs has no mainline driver). Cellular modem support varies by device. Independent coverage in 2026 characterises the platform as “a credible development target and an increasingly functional enthusiast platform” rather than a daily-driver replacement for Android or iOS14.

Pick this over Option A if: you want full Linux on the phone hardware itself rather than hardened Android, you can tolerate the rough edges, and you’re prepared to roll back to GrapheneOS or stock if something doesn’t work. For most readers, Option A is the better trade.

Dedicated Linux phones: PinePhone Pro and Librem 5 (most radical)

The PinePhone Pro (Pine64, approximately $399 base) and Librem 5 (Purism, approximately $799 with current promos) are phones designed for Linux from the ground up. PinePhone Pro runs PostmarketOS, Mobian, or one of several other distros; Librem 5 runs PureOS by default. Both have hardware kill switches for the radios and cameras: a feature no Pixel offers.

What you get: an open Linux mobile device with hardware designed around the open Linux mobile stack. The kill switches are physical circuit breaks, not software toggles.

What you give up: battery life is measured in hours, not days (PinePhone Pro especially is known for 3–5 hours of active use). Camera quality is below 2019 Android-flagship level. Cellular reliability ranges from “fine on common bands” to “intermittent.” Purism’s fulfilment history is poor (multi-year delays on the original Librem 5 deliveries). Software maturity remains rough enough that long-time users describe both phones with affection-tinged frustration.

Pick this if: hardware kill switches are non-negotiable, the phone is primarily a Linux mobile device rather than a working daily phone, and you have patience for the maturity gap.

For most readers asking “phone as workstation,” the Pixel hardware path above is the answer.

What to avoid

  • Microsoft Surface devices. Linux runs (the linux-surface kernel-fork project exists and is competent), but Microsoft’s hardware roadmap does not target you, touch and pen integration are half-supported, and you’re paying the Surface premium to fight the platform.
  • Laptops with Nvidia Optimus (discrete GPU plus Intel iGPU switching). Suspend-resume is unreliable on most Linux configurations, switching the active GPU is fiddly, and battery life suffers. If you need Nvidia, prefer a discrete-only laptop or a desktop.
  • Anything with a vendor-locked bootloader you can’t disable. Most Chromebooks (though the Crostini path exists for those who commit to it), Microsoft Surface laptops with Pluton enforced, and “AI PC” hardware with vendor-mandated NPU firmware running continuously.
  • 2-in-1 / tablet hybrids. Touch support on Linux is competent on GNOME and KDE but worse than on Android, iPadOS, or Windows. If you want a tablet, get an iPad and accept it for what it is, or get a phone with desktop mode (Tier 3 above).

The MacBook case has its own subsection inside Tier 2 (#the-macbook-trap) and is not repeated here.


  1. Engadget, Lenovo’s ThinkPads get a spec bump at MWC 2026, 1 March 2026: https://www.engadget.com/computing/laptops/lenovos-thinkpads-get-a-spec-bump-at-mwc-2026-230100419.html. Confirms ThinkPad T14 Gen 7 and T16 Gen 5 starting at $1,799 with Intel Core Ultra Series 3 (vPro) or AMD Ryzen AI Pro 400 Series CPUs, optional 5MP camera with computer vision/vHDR, larger speakers, and an iFixit 10/10 repairability score. T14s Gen 7 starts at $1,899. Most devices shipping Q2 2026. ↩ ↩2 ↩3

  2. Techlicious, Turn your Pixel into a PC with the new Desktop Mode, 26 March 2026: https://www.techlicious.com/tip/pixel-desktop-mode/. Plugable, How to Use Android 16 Desktop Mode with a Pixel Phone and USB-C Hub or Adapter, knowledge-base article current as of 1 April 2026: https://kb.plugable.com/how-to-use-android-16-desktop-mode-with-a-pixel-phone-and-usb-c-display-or-hub. Confirms Desktop Mode requires a Pixel device that supports DisplayPort Alt Mode over USB-C (Pixel 8 series and newer); shipped on stable Android 16 (March 2026 Feature Drop, Android 16 QPR3 baseline); Google confirmed at I/O 2025 the implementation is built on Samsung DeX’s foundations. ↩ ↩2

  3. PiunikaWeb, Why GrapheneOS users will have to wait for stable Android 17 to get Google’s new desktop mode, 16 April 2026: https://piunikaweb.com/2026/04/16/grapheneos-android-17-desktop-mode/. Explains that QPR3 made the polished desktop session generally available on supported Pixel and Samsung devices, but that Google’s 2026 AOSP cadence has slowed downstream propagation to custom OS projects; GrapheneOS already has an experimental desktop mode usable today with USB-C to HDMI/DP setup plus keyboard and mouse, with the stable user-facing option expected when GrapheneOS moves to Android 17. ↩ ↩2 ↩3

  4. Phoronix, Asahi Linux Has Experimental Code For DisplayPort, Apple M3/M4/M5 Bring-Up Still Ongoing, 31 December 2025: https://www.phoronix.com/news/Asahi-Linux-EOY-2025-CCC. Coverage of Sven Peter’s 39C3 presentation. Documents that M4/M5 changes have broken the existing Asahi Linux reverse-engineering tools, that the display controller and GPU driver remain the biggest pieces not yet upstreamed for M1/M2, and that M4/M5 Linux support is expected to take significant additional time. Original Sven Peter Mastodon post from April 2025 characterizing M4 support as “rather painful” referenced in earlier Phoronix and Apple Insider coverage; SPTM-at-GL2 / MMU-at-EL2 detail from the same post. ↩ ↩2 ↩3

  5. How-To Geek, Asahi Linux Gets a Reboot, Still Working On M3 & M4 Mac Support, 13 February 2025: https://www.howtogeek.com/asahi-linux-reorganization-m3-m4-mac-support/. Documents Hector Martin’s departure from the Asahi Linux project, the seven-person successor team’s organizational restructure, and the focus on upstreaming the 1000+ downstream patches before prioritizing new hardware. Quotes Martin on the difficulty of being “in a position to have to upstream code across practically every Linux subsystem, touching drivers of all categories as well as some common code”; also covers the Rust-in-kernel argument as a contributing factor. ↩ ↩2

  6. NotebookCheck, Now available to order in many countries: Panther Lake powered Lenovo ThinkPad X1 Carbon Gen 14 releases, 9 March 2026: https://www.notebookcheck.net/Lenovo-releases-new-14-inch-ThinkPad-globally-with-120-Hz-VRR-OLED.1246063.0.html. X1 Carbon Gen 14 with Intel Panther Lake replaces the Lunar Lake-based Gen 13. Three Thunderbolt 4 ports, 58 Wh battery, optional 120 Hz VRR OLED display. Configurable without an OS at a £50/€60 discount. ↩

  7. Libreboot release notes, Libreboot 20241206 released! ThinkPad T480 added. Plus U-Boot UEFI on x86. Fixes for OptiPlex 3050 Micro., 6 December 2024: https://libreboot.org/news/libreboot20241206.html. T480/T480s support implemented by Mate Kukri with testing and hardware logs provided by Leah Rowe; uses Mate’s rewritten deguard for ME configuration, which now supports the 3050, T480, and T480S with machine-specific configurations. Install procedure: https://libreboot.org/docs/install/t480.html — both me_cleaner and deguard applied; Intel graphics, internal screen, ethernet, USB, WLAN, HDA verbs working. ↩

  8. Libreboot release notes, Libreboot 26.01 RC1 “Tenacious Tomato” released!, 25 December 2025: https://libreboot.org/news/libreboot2601rc1.html. Refinements for T480/T480s including headphone-jack detection (pavucontrol no longer required to switch the port manually). Linuxadictos coverage, Libreboot 26.01 expands support to HP Pro 3500, Topton X2E N150, ThinkPad T580 and Dell Latitude E7240, 5 February 2026: https://en.linuxadictos.com/Libreboot-26.01-expands-support-to-HP-Pro-3500--Topton-X2e-N150--ThinkPad-T580--and-Dell-Latitude-E7240.html. Documents 26.01 stable on 30 January 2026 and notes that T480/T480s Thunderbolt support was added in RC1 then removed before the final 26.01 release due to S3-resume regressions on some units. ↩

  9. LinuxTeck, Fedora Asahi Remix 43 Arrives — And It’s The Most Complete Apple Silicon Linux Release To Date, 19 March 2026: https://www.linuxteck.com/fedora-asahi-remix-43-apple-silicon/. Documents 18 March 2026 joint release of Fedora Asahi Remix 43 by the Asahi Linux project and the Fedora community: Mac Pro with Apple Silicon support added; 120Hz display refresh on MacBook Pro; M2 Pro/Max internal microphone fix; RPM 6.0 plus DNF5 packaging. ↩

  10. AppleInsider, It’s not usable yet but Asahi Linux runs on M3 Macs now, 27 January 2026: https://appleinsider.com/articles/26/01/27/its-not-usable-yet-but-asahi-linux-runs-on-m3-macs-now. Reports Fedora 43 Asahi Remix running on an M3 Mac with KDE Plasma in software rendering (LLVMpipe); contributor IntegralPilot posted the photo. Quote on M3 state (“Basically ONLY the internal SSD, display, keyboard, and trackpad work”) attributed to one of the driver creators. ↩

  11. Plugable, How to Use Android 16 Desktop Mode with a Pixel Phone and USB-C Hub or Adapter: https://kb.plugable.com/how-to-use-android-16-desktop-mode-with-a-pixel-phone-and-usb-c-display-or-hub. Names DisplayPort Alt Mode as the required cable/hub feature and confirms that charge-only USB-C cables will not pass video. ↩

  12. Droid Life, 2026 March Pixel Update is here, what’s in it?, 3 March 2026: https://www.droid-life.com/2026/03/03/2026-march-pixel-update-download/. Records the March 2026 Pixel Feature Drop as both a Feature Drop and the quarterly Android 16 QPR3 update. ↩

  13. postmarketOS, Install postmarketOS (current stable version): https://postmarketos.org/install/. Confirms v25.12 as current stable; v26.06 in development per the 10 May 2026 update post: https://postmarketos.org/blog/2026/05/10/pmOS-update-2026-04/. ↩

  14. SitePoint, State of Linux Mobile 2026: PostmarketOS & F-Droid Updates, 27 February 2026: https://www.sitepoint.com/postmarketos-fdroid-2026-status/. Source for the “credible development target and an increasingly functional enthusiast platform” characterization and the estimate of ~723 supported device models as of February 2026. Also documents the generic mainline kernel strategy and the postmarketOS 25.06 reorganization of device categories. ↩ ↩2 ↩3

Devuan Secure Workstation

  • For: someone who has chosen Devuan and wants the secure default rather than the default default.
  • Before you start: Choosing the OS, and Full disk encryption with LUKS2 + LVM on Devuan, which is the install this document builds on.
  • Reading time: about 56 minutes end to end; each part stands alone.
  • You end with: an encrypted, hardened daily-driver workstation with backups, a firewall, encrypted DNS and, optionally, Tor-routed VMs.

A complete install-plus-hardening procedure for a single-user Devuan workstation with full-disk encryption. Covers everything from the USB stick to a hardened daily driver. Written for someone who already chose Devuan per the OS guide and wants the secure default rather than the default default.

The install itself is in Full disk encryption with LUKS2 + LVM on Devuan, which walks through the partitioning, LUKS, LVM and GRUB by hand. This document covers what to do before it and the hardening after it.

TL;DR

The install guide gets you a working LUKS-LVM Devuan install with a single passphrase prompt at boot. After first login you do this, in order:

  1. Back up the LUKS header off the disk before anything else.
  2. Set a GRUB superuser password.
  3. Set a firmware (BIOS/UEFI) supervisor password.
  4. Set up off-machine encrypted backups with Borg, before adding any data you care about.
  5. Install CPU microcode for your vendor.
  6. Update vendor firmware (BIOS, Thunderbolt, NVMe) via fwupd where available.
  7. Set up unattended security-only updates.
  8. Apply kernel hardening sysctls and boot parameters.
  9. Install and enable AppArmor profiles.
  10. Set up MAC address randomisation for WiFi and Ethernet.
  11. Switch to encrypted DNS via dnscrypt-proxy.
  12. Install USBGuard with a whitelist of your existing devices.
  13. Authorize Thunderbolt devices explicitly via boltctl and tighten IOMMU settings (if your hardware has Thunderbolt).
  14. Set up a default-deny host firewall with nftables.
  15. (Optional, breaks things) Enable kernel lockdown mode.

Items 1 through 14 are mandatory for any threat model that includes a stolen laptop or a network you don’t fully control. Item 15 trades capability for reduction in post-compromise damage; enable it only if you understand what it costs.

What this produces

A Devuan workstation with the following properties when complete:

  • Full-disk encryption with a single passphrase prompt before GRUB
  • Encrypted swap (the swap LV sits inside the LUKS container)
  • Encrypted /tmp and /home (likewise inside LUKS) plus tmpfs /tmp for runtime
  • No systemd (sysvinit, OpenRC, or runit as the init system; default sysvinit)
  • A signed bootloader chain you can verify against the LUKS header backup
  • AppArmor mandatory access control on top of standard Unix permissions
  • Kernel hardened against the common local-privilege-escalation paths
  • Off-machine encrypted backups via Borg
  • Encrypted DNS resolution
  • MAC randomisation on network interfaces
  • USB device whitelist enforcement
  • Thunderbolt devices require explicit authorisation on first connect (where applicable)
  • Off-machine alerts on host-integrity changes (see the companion HIDS guide)

What this is not. Not a Tor-routed system by default (use Tails or Whonix for that: Part 4.5 covers the Whonix-on-this-workstation option). Not amnesic (use Tails). Not the maximalist VM compartmentalisation model (use Qubes for that). Not signed-boot-enforced from firmware (Secure Boot is off; see the trade-off in the next section). The threat model assumes a competent attacker with physical access to a powered-off laptop, network-level observers, and ordinary malware, not a nation-state with hardware implants.

Architectural costs

Three deliberate choices the install makes against the most-paranoid alternative, so you know what you’re trading.

LUKS2 with PBKDF2, not Argon2id. Argon2id is the modern key derivation function (memory-hard, side-channel-resistant). PBKDF2 is older and weaker against GPU attacks. The install uses PBKDF2 because GRUB’s LUKS2 support does not yet handle Argon2id; if you format with Argon2id, GRUB cannot read /boot and the system does not boot. Two ways out: put /boot on a separate USB stick that you don’t unlock via GRUB (covered in Part 4.2, full Argon2id becomes possible), or accept PBKDF2 with a long, high-entropy passphrase. The script picks the second, with PBKDF2 hardened by LUKS_ITER_TIME=5000 (default): five seconds of derivation per guess, ~2.5× the cryptsetup default. This is the strongest PBKDF2 setting that doesn’t pessimise boot.

This constraint has an expiry date. GRUB 2.14, released 13 January 2026, adds native Argon2 KDF support. Devuan Excalibur (and Debian Trixie behind it) ships GRUB 2.12 and will keep PBKDF2 for the lifetime of this release. The next Devuan stable (Freia, based on Debian Forky, no fixed release date yet) is expected to ship GRUB 2.14 or later, at which point the PBKDF2 workaround stops being necessary and a re-encrypt to Argon2id (cryptsetup luksConvertKey --pbkdf argon2id) becomes the cleanup task.

Secure Boot off. Devuan does not ship signed shim/grub binaries for Secure Boot the way Ubuntu and Fedora do. Leaving Secure Boot on would mean either signing your own bootloader chain (real work, breaks on every kernel update unless automated) or running an unsigned bootloader that Secure Boot then refuses. The script assumes Secure Boot is off. The cost: a sufficiently determined evil-maid attacker can install a malicious bootloader. Mitigations covered in Part 4.

No TPM-sealed unlock by default. A TPM2-sealed LUKS key with PCR binding gives unlock-without-typing-the-passphrase, with the unlock conditional on the firmware and bootloader being unmodified. It’s a real security improvement but it interacts badly with firmware updates and kernel updates, and the recovery path is harder. The default install uses passphrase prompt. TPM2-sealed unlock is covered as an optional upgrade in Part 4.1.

GRUB 2.14 also adds a native TPM2 key protector. On the current Excalibur GRUB 2.12, the TPM2 enrolment path is clevis-luks-bind plus clevis-tpm2 (Part 4.1). Once Devuan Freia ships GRUB 2.14, native TPM2 unlock at the bootloader level becomes the cleaner path and clevis-on-Devuan becomes the legacy approach.

What’s outside encryption

Exactly one partition: the EFI System Partition (ESP), /dev/<disk>1, 1 GiB, FAT32, mounted at /boot/efi. Everything else (the kernel, the initramfs, /boot proper, /, /home, swap) sits inside the LUKS2 container on /dev/<disk>2. The LUKS header (the first ~16 MiB of <disk>2) is also on disk but is encrypted-metadata, not plaintext.

The ESP cannot be encrypted in this architecture because UEFI firmware needs to read it before any decryption could happen. This is the structural reason “evil maid” is the residual threat: an attacker with physical access to a powered-off machine can modify grubx64.efi on the ESP to inject a passphrase-capturing payload, and your next boot types the LUKS passphrase straight into their malware. Secure Boot would defend against this by refusing to load a tampered grubx64.efi, but Devuan ships unsigned GRUB so Secure Boot is off, so this defence is unavailable in the stock setup.

The genuinely strongest configurations for “nothing sensitive in cleartext on the laptop’s internal storage”:

PathWhat lives in cleartext on the laptopEvil-maid surface
Default install (this doc, Parts 1–3)ESP with grubx64.efi on the internal diskModify grubx64.efi on the internal ESP
/boot on separate USB (Part 4.2)Nothing on the internal diskModify grubx64.efi on the USB you carry
Heads / coreboot (Part 4.3)A measured open firmware in the SPI flash chipOpen laptop, clip onto SPI flash with specialised hardware

Genuine “everything inside encryption including the ESP” is impossible because something has to boot first. The closest practical answer for “nothing sensitive in cleartext on the laptop’s internal storage” is /boot on a separate USB key (Part 4.2); the closest answer for “even the firmware loading the bootloader is integrity-protected” is Heads/coreboot (Part 4.3). The Knots-lens / cold-storage operator practice converges on running one or both.

A note on where GRUB is installed. GRUB writes the bootloader to /EFI/devuan/grubx64.efi and creates an NVRAM boot entry pointing at it. The installer’s question “Force GRUB install to EFI removable media path?” adds a second copy at /EFI/BOOT/BOOTX64.EFI (the firmware fallback path), which adds reliability (NVRAM cleared by a firmware update or a flat motherboard battery still boots) at the cost of one more ESP file an attacker with physical access could swap. The install guide answers Yes; the minimum attack surface is No. The reliability cost is asymmetric: if NVRAM gets cleared without the fallback, you boot from the install USB and run grub-install again, about five minutes’ work. The attack-surface cost is permanent until the file is removed. Answer Yes only when your firmware is known to ignore NVRAM (some older Macs, some buggy UEFI), or when the disk needs to boot on multiple machines.

Order of operations

This document is structured so that you can read it once, top to bottom, on a fresh install. The order matters: later parts assume the earlier parts are done. If you skip ahead, check the section preamble for prerequisites.

Part 1: Install. Pre-install preparation, then the full-disk encryption guide.

Part 2: First-boot essentials. Three things you must do before anything else (LUKS header backup, GRUB password, /tmp tightening that the install guide already configured but you should verify).

Part 3: Runtime hardening, in priority order. Each part is independently applicable.

Part 4: Architectural upgrades. Optional, more disruptive, do these last.

Part 5: Recovery from failed boot. Reference material for when something breaks.

Part 1: Install

1.1 What you need before you install

  • A target machine that boots UEFI. Disable Secure Boot in firmware setup before booting the installer.
  • A spare USB stick of at least 4 GB to write the Devuan installer to.
  • A second USB stick of at least 1 GB for the LUKS header backup (Part 2.1). Can be smaller; a 1 GB stick is just what you’ll find lying around.
  • A passphrase plan. The LUKS passphrase needs to be long and memorable and you must not lose it. See Creating passphrases for how to make one and keep it.

1.2 Downloading and verifying the installer ISO

Get the Devuan netinst ISO from https://www.devuan.org/os/download for the current stable release. As of this writing that’s Excalibur 6.1 (point release dated 2 January 2026), based on Debian Trixie, shipping Linux 6.12 LTS, cryptsetup 2.7.x, and GRUB 2.12. Devuan publishes SHA256SUMS and SHA256SUMS.asc alongside the ISO. Verify both.

sha256sum -c SHA256SUMS --ignore-missing
gpg --verify SHA256SUMS.asc SHA256SUMS

If gpg complains it doesn’t know the signing key, fetch it from the Devuan keyring page (linked from the download page) and verify the key fingerprint against multiple independent sources before trusting it. The fingerprint of the Devuan release key has been stable; cross-check it against the project’s official channels (devuan.org, the Devuan announce mailing list archive, the dev1galaxy forum) rather than a single source.

Write the ISO to USB:

sudo dd if=devuan_excalibur_amd64_netinst.iso of=/dev/sdX bs=4M status=progress conv=fsync

Replace sdX with the actual device. Get this wrong and you overwrite a different disk. lsblk before, lsblk after.

1.3 Install with full-disk encryption

Boot the target machine from the USB stick and follow Full disk encryption with LUKS2 + LVM on Devuan from its first part to its last. It uses the Devuan installer’s own menus, drops to a shell for the partitioning and encryption, and returns to the installer for the rest. Read it once before you start.

Decide these before you begin:

  • Which disk to wipe. Get the device name from lsblk (sda, nvme0n1). Get this wrong and you overwrite a different disk.
  • Swap size. The guide uses 4 GB. Hibernation needs swap at least the size of RAM, and is incompatible with the kernel lockdown step in Part 3.12, so pick one.
  • Root size. The guide uses 40 GB, which is comfortable for a workstation that keeps its data in /home.
  • Whether to install GRUB to the firmware fallback path as well. See the note under “Architectural costs” above.

What you end up with:

  1. A GPT disk with a 1 GB EFI System Partition and a LUKS2 partition spanning the rest, using PBKDF2 (GRUB cannot unlock Argon2id) and AES-XTS.
  2. An LVM volume group inside LUKS with logical volumes for /boot, swap, / and /home, formatted ext4 and swap.
  3. A random keyfile, added as a second LUKS key and embedded in the initramfs, so the boot prompts for the passphrase exactly once at the GRUB stage and the keyfile unlocks everything else.
  4. GRUB with GRUB_ENABLE_CRYPTODISK=y, so it can read the encrypted /boot.
  5. /tmp on tmpfs (the guide’s last part; verified in Part 2.4 below).
  6. The root account locked, so all privileged work goes through sudo and leaves an audit trail.

1.4 First boot

Reboot, remove the USB stick. GRUB starts, prompts for the LUKS passphrase, decrypts /boot, loads the kernel and initramfs, initramfs uses the embedded keyfile to unlock the same LUKS container without re-prompting, init takes over, you reach the login screen. Single passphrase prompt total.

Log in as your user. You now have a working Devuan install. The hardening starts here.

Part 2: First-boot essentials

Three things to do before anything else.

2.1 LUKS header backup

The LUKS header sits in the first few megabytes of the encrypted partition. It contains the encryption parameters and the key slots that hold copies of the master key encrypted under each passphrase. Corrupt those few megabytes (one bad write, one filesystem bug, one stray dd to the wrong device) and the entire disk is unrecoverable even with the correct passphrase. The header backup is the single most important file you can keep about this machine.

Identify the LUKS partition. It’s /dev/<disk>2 or /dev/<disk>p2 depending on whether the disk uses p partition naming:

sudo cryptsetup luksDump /dev/nvme0n1p2 | head -5

If that reports a LUKS2 header, that’s the right device.

Plug in the spare USB stick you set aside in Part 1.1. Identify its partition with lsblk. Mount it. Then:

sudo cryptsetup luksHeaderBackup /dev/nvme0n1p2 \
    --header-backup-file ~/luks-header.bin

Encrypt the backup before it leaves the machine. The Knots-lens / cold-storage operator choice here is GPG symmetric encryption rather than age. GPG (OpenPGP) is older, has a formal RFC, has multiple independent implementations, has been battle-tested for thirty years, and the format will still be readable in 2050. age is well-designed but is a single-developer Go project (Filippo Valsorda, 2019) with no formal audit and a comparatively short track record. For a file you need to be able to decrypt twenty years from now from a recovery USB, GPG wins on the durability axis.

gpg --symmetric --cipher-algo AES256 \
    --s2k-mode 3 --s2k-count 65011712 --s2k-digest-algo SHA512 \
    -o ~/luks-header.bin.gpg ~/luks-header.bin
shred -u ~/luks-header.bin

The flags pick AES-256 and the strongest available S2K (string-to-key) parameters: SHA-512 hashing with a 65-million-iteration count, which makes the passphrase-to-key derivation deliberately slow. GPG prompts for a passphrase. Use a different passphrase than the LUKS passphrase. Store this passphrase on paper, in a different physical location than the USB stick. The threat model: if both the laptop and the USB stick are stolen together, the attacker has the encrypted header but not the passphrase to decrypt either.

Copy ~/luks-header.bin.gpg to the USB stick. Then to a second USB stick. Then to a third location (encrypted off-site backup, trusted family member’s house, safe-deposit box). Three copies in three locations is the minimum.

Verify each copy decrypts:

gpg -d ~/luks-header.bin.gpg > /tmp/test-header.bin
cmp /tmp/test-header.bin <(sudo cryptsetup luksHeaderBackup /dev/nvme0n1p2 --header-backup-file /dev/stdout)
shred -u /tmp/test-header.bin

The cmp should report no output (the files are identical). If it doesn’t, the backup is bad and you need to redo it.

The age alternative remains valid for users who prefer simpler tooling and accept the durability tradeoff: age -p -o ~/luks-header.bin.age ~/luks-header.bin produces a working encrypted backup that age 1.x and forward will read. For maximum long-term durability, GPG symmetric is the safer default.

After every passphrase change (Appendix A), redo the header backup. The old backup will not unlock the new passphrase; if you forget the new passphrase and your only header backup is from before the change, you’ve trapped yourself with the old passphrase that no longer works.

2.2 GRUB superuser password

GRUB without a superuser password lets anyone who reaches the GRUB menu edit the kernel command line, add init=/bin/bash, and boot into a root shell. That root shell runs against your decrypted root filesystem, which is in memory from the moment the LUKS passphrase was entered. An attacker with a few seconds at your unlocked-but-not-logged-in machine can use this. Set a GRUB password.

Generate a PBKDF2 hash of the password (different from the LUKS passphrase; this one is recoverable, not catastrophic-if-lost):

grub-mkpasswd-pbkdf2

Enter a password, confirm, copy the output (grub.pbkdf2.sha512.10000.AAAA...).

Create /etc/grub.d/40_custom_password:

sudo tee /etc/grub.d/40_custom_password > /dev/null <<'EOF'
#!/bin/sh
exec tail -n +3 $0
set superusers="root"
password_pbkdf2 root grub.pbkdf2.sha512.10000.PASTE_YOUR_HASH_HERE
EOF
sudo chmod +x /etc/grub.d/40_custom_password

Replace PASTE_YOUR_HASH_HERE with the full string from grub-mkpasswd-pbkdf2.

Update GRUB:

sudo update-grub

Reboot to verify. You should still be prompted for the LUKS passphrase (which is GRUB unlocking /boot to read the kernel). Then if you try to edit a menu entry (press e), GRUB now demands the root username and the password you just set.

Trade-off: this prevents an attacker from modifying boot parameters but does not prevent them from rebooting from external media. That’s what Part 4 is for.

2.3 BIOS/UEFI password

The GRUB password (Part 2.2) sits above the firmware. An attacker with physical access can still enter the firmware setup, change boot order to a USB stick, disable Secure Boot if it was on, or in some cases reset the boot password via a CMOS clear. Setting a firmware password closes most of those.

Two firmware passwords are typically available, with different roles:

  • Supervisor password (sometimes called admin or BIOS password). Required to enter setup. Without it, an attacker can’t change firmware settings, can’t toggle Secure Boot, can’t change boot order, can’t disable hardware features. This is the one to set.
  • Power-on password (sometimes called user password). Required to boot the machine at all. Forces a prompt before the firmware even loads the bootloader. Stronger but more disruptive: every cold boot needs the password, before GRUB and before LUKS.

For most threat models, the supervisor password alone is sufficient. The power-on password adds a layer at the cost of an additional prompt; pick based on whether your threat model includes attackers who can casually power on your laptop.

The procedure is firmware-vendor-specific. Generic path:

  1. Reboot, enter firmware setup (F2, F12, Del, or Esc during POST: varies by vendor; the screen usually tells you).
  2. Navigate to a Security tab.
  3. Find “Supervisor Password,” “Admin Password,” or “BIOS Password”: set it.
  4. (Optional) Also set “Power-on Password” or “User Password.”
  5. Save and exit.

Honest framing of the limits. Firmware passwords on consumer hardware are not strong against a determined attacker with the machine in their possession for hours:

  • CMOS clear. Many laptops have a clear-CMOS jumper or coin-cell battery that, when removed for a minute, resets firmware settings including passwords. ThinkPads use a security chip and don’t fully reset on CMOS clear, but most other vendors do. Check your laptop’s service manual.
  • EEPROM access. A skilled attacker with bench tools (SOIC clip, external SPI programmer) can directly read or write the firmware chip, bypassing any password. This is the same threat surface as Heads/coreboot mitigates by replacing the firmware itself.
  • Vendor recovery procedures. Some vendors (Dell, HP) have documented service procedures to bypass firmware passwords with proof of ownership. An attacker with social engineering access and your laptop can sometimes exploit these.

For threat models worried about CMOS clear and EEPROM access, the answer is Heads/coreboot (Part 4.3), not a firmware password. The firmware password is a useful defence against casual physical access (lost laptop, opportunistic theft), not against a determined adversary who has the device for a week with bench tools.

Recovery if you forget your own firmware password. Different vendors handle this differently and the recovery story is part of what to know before setting the password:

  • ThinkPad supervisor password. Stored in the security chip, not in CMOS. Clearing CMOS does NOT reset the supervisor password: this is the security chip’s main job. The official recovery procedure is the Lenovo Service Center with proof of ownership; Lenovo can flash-reset the chip but does not provide consumer-side master passwords. Practically: if you forget, the laptop is bricked from a firmware-config perspective until a Service Center session, which is days-to-weeks of turnaround. Write the password down somewhere offline before setting it.
  • Dell BIOS password. Tied to the laptop’s service tag. Dell has a documented procedure for owners to request a master password with proof of purchase; the password is generated from the service tag. The procedure is real, has worked historically, and is also an attack surface: anyone who can social-engineer Dell support with a stolen laptop’s service tag may get the same recovery.
  • HP BIOS password. Varies by model. Older business-class laptops (EliteBook, ProBook) have HP-side recovery via a service technician; consumer-class HP often requires motherboard replacement to recover.
  • Most other vendors. A CMOS clear (remove the coin-cell battery for 60 seconds with AC disconnected) resets the firmware password on most consumer hardware. This is why the firmware password limits subsection earlier in this section names CMOS clear as a defeat: on non-ThinkPad hardware, your own forgetfulness and a determined attacker face the same low bar.

Net guidance: set the password, write it down on paper, store the paper with your LUKS-header-backup USB stick (Part 2.1). If your hardware is ThinkPad-grade with a real security chip, the password is meaningful protection against an attacker; on most consumer hardware, the password is meaningful only against casual access, with CMOS clear closing the gap for a determined attacker.

2.4 Verify /tmp hardening

The install guide’s last part puts /tmp on tmpfs with noexec,nosuid,nodev,nosymfollow,size=2G. Confirm it took effect:

mount | grep ' /tmp '

Should report type tmpfs with nosuid and nodev in the options. If not, reboot once and check again.

nosuid means setuid binaries placed in /tmp don’t run with elevated privileges, killing one common privilege-escalation path. nodev means device nodes in /tmp don’t work. size=50% caps memory usage so a runaway process can’t OOM the machine by filling /tmp.

The same treatment for /var/tmp requires more care because some software stores cross-reboot state there. The conservative path: leave /var/tmp alone for now.

Part 3: Runtime hardening

Each section is independently applicable. The order is by importance: do them top to bottom and stop wherever your patience runs out. Doing the first three (backup, microcode, sysctls) catches the largest fraction of realistic threats.

3.1 Off-machine encrypted backup with Borg

The single most important reliability item. Disks fail. Ransomware encrypts. Laptops vanish. Without backup, any of those is total loss. Do this before adding any data you care about, so the first snapshot captures the clean state and every later snapshot is an incremental delta.

Why Borg as the default. Borg (borgbackup) is older, written in Python with the hot paths in C, and has been the de-facto default for serious operators for over a decade. The on-disk format is documented and stable across versions; the codebase has been audited multiple times by independent reviewers; the Python ecosystem means you can audit one library at a time. restic is also good (single static Go binary, simpler mental model, better cloud-native backends), but the Knots-lens / cold-storage operator preference leans Borg for the same reason it leans GPG over age: fewer dependencies, longer track record, more eyes on the format. Both encrypt, both deduplicate, both compress. Either is dramatically better than no backup. For this procedure the destination is a local encrypted drive, where Borg is the clear default; restic’s advantage is cloud object storage, which this procedure does not use. The full tool comparison and the backup discipline (3-2-1, off-site rotation, append-only, restore testing) are in Planning; this section is the Devuan procedure that implements them.

Install:

sudo apt install borgbackup

Prepare an external disk

Plug in an external drive. Get the device name from lsblk. Treat the next step as destructive; double-check the device.

LUKS-encrypt the external disk:

sudo cryptsetup luksFormat --type luks2 /dev/sdX1
sudo cryptsetup open /dev/sdX1 backup
sudo mkfs.ext4 -L backup /dev/mapper/backup

Mount:

sudo mkdir -p /mnt/backup
sudo mount /dev/mapper/backup /mnt/backup
sudo chown $USER:$USER /mnt/backup

Initialise the Borg repository

borg init --encryption=repokey-blake2 /mnt/backup/borg-repo

The repokey-blake2 mode stores the encrypted master key inside the repo itself (protected by your passphrase) and uses BLAKE2b for the MAC instead of HMAC-SHA256: faster and equally secure. The passphrase here must be different from your LUKS passphrases. Borg uses it to unwrap the master key in the repo; lose it and every archive in this repo is unrecoverable. Store it the same way you store the LUKS passphrase: paper, separate location.

For a paranoid-mode variant, --encryption=keyfile-blake2 stores the master key in ~/.config/borg/keys/ instead of in the repo. The repo itself then leaks no key material. Cost: you have to back up the keyfile separately (otherwise losing the workstation loses the backup). Use repokey-blake2 unless you have a specific reason to want keyfile mode.

A daily snapshot

Create /usr/local/sbin/borg-backup:

sudo tee /usr/local/sbin/borg-backup > /dev/null <<'EOF'
#!/bin/bash
set -euo pipefail

export BORG_REPO=/mnt/backup/borg-repo
export BORG_PASSCOMMAND="cat /root/.borg-password"

ARCHIVE_NAME="$(hostname)-$(date +%Y-%m-%dT%H:%M:%S)"

borg create \
    --verbose --filter AME --list --stats --show-rc \
    --compression zstd,9 \
    --exclude-caches \
    --exclude '/home/*/.cache' \
    --exclude '/home/*/.local/share/Trash' \
    --exclude '/var/cache' \
    --exclude '/var/tmp' \
    --exclude '/tmp' \
    "::${ARCHIVE_NAME}" \
    /home /etc /root /usr/local

borg prune --list \
    --keep-daily 14 --keep-weekly 8 --keep-monthly 12 \
    --show-rc

borg compact
EOF
sudo chmod 700 /usr/local/sbin/borg-backup

Write the repo passphrase to /root/.borg-password (root-only readable):

sudo touch /root/.borg-password
sudo chmod 600 /root/.borg-password
sudo nano /root/.borg-password   # paste the passphrase, one line, no trailing newline

Test the backup manually first:

sudo /usr/local/sbin/borg-backup

It should report files processed and an archive ID. List archives:

sudo BORG_REPO=/mnt/backup/borg-repo BORG_PASSCOMMAND="cat /root/.borg-password" borg list

Schedule via cron. On Devuan with sysvinit, cron is the right job runner (not systemd timers). Edit root’s crontab:

sudo crontab -e

Add (runs at 03:17 daily; mount the backup disk first or skip if not mounted):

17 3 * * * /usr/local/sbin/borg-backup 2>&1 | logger -t borg-backup

Three locations, monthly rotation

Every backup that lives in the same building as the original is one fire, flood, or break-in away from being no backup. The operational floor for long-term durability is three encrypted drives in three physical locations:

  • Drive A, attached to the workstation (or nightly-mountable from it), receives the nightly cron snapshot.
  • Drive B, identical setup, stored at a different physical location: a relative’s house, a safe-deposit box, an office locker. Rotated with Drive A monthly: you bring B home, swap them, take A back to the off-site location, B is now the nightly target. The off-site drive is at most one month stale.
  • Drive C, deep-archive: stored in a third location (different city, with family, at a friend’s), updated quarterly or semi-annually. Survives the case where both your home and your monthly off-site location are compromised simultaneously (fire across a neighbourhood, regional natural disaster, coordinated theft).

Borg makes drive-to-drive sync straightforward: borg list and borg export-tar work on any mounted repo. A simpler pattern: keep all three drives as independent Borg repos and run the same backup script against each in turn during the rotation.

Biannual restore drill

Untested backup is not a backup. Twice a year, do a full restore drill to a sacrificial location:

sudo mkdir -p /mnt/restore-drill
sudo BORG_REPO=/mnt/backup/borg-repo BORG_PASSCOMMAND="cat /root/.borg-password" \
    borg extract --list ::"$(borg list --short | tail -1)" \
    --strip-components 0 -- '*'
# inspect, verify a sample of files match production
# then clean up:
sudo rm -rf /mnt/restore-drill

Pick a specific calendar date: January 1st and July 1st, or your birthday and the six-month-offset day. Block it on your calendar. The restore drill catches: silently-corrupted repos, passphrase memory drift (you remembered the wrong word), encryption-tool API drift between Borg versions, drive failure that hadn’t surfaced yet. Find the failure during the drill, not during the emergency.

Save the LUKS header to the backup drive too

The LUKS header backup from Part 2.1 should also live on the backup drive (in addition to the other locations). It’s small; replicate it everywhere reasonable.

cp ~/luks-header.bin.gpg /mnt/backup/

If you’re already on restic

restic remains a valid choice. apt install restic, the v3 procedure with restic init --repo, restic backup, restic forget --keep-daily / --keep-weekly / --keep-monthly --prune still works and is well-tested. The Knots-lens default is Borg; the practical-equivalence default is whichever tool you’ll actually maintain.

3.2 CPU microcode

Closes the speculative-execution attack family (Spectre, Meltdown, Downfall, Inception, Zenbleed, and the others that will be discovered next year). Microcode is loaded by the bootloader before the kernel; on Devuan the relevant package installs it into the initramfs automatically.

Find your CPU vendor:

grep -m1 vendor_id /proc/cpuinfo

If it says GenuineIntel:

sudo apt install intel-microcode

If it says AuthenticAMD:

sudo apt install amd64-microcode

If the installer already pulled in both packages, the right one is in place. This step is the verify-and-confirm pass for an existing install.

Reboot, then verify the microcode loaded:

dmesg | grep -i microcode

You should see a line about microcode being updated early, naming a revision number. If you see “microcode updated late” or nothing at all, the package isn’t installed for your vendor or the initramfs isn’t pulling it in. Re-run update-initramfs -u -k all and reboot again.

3.3 Firmware updates via fwupd

Microcode (Part 3.2) closes CPU-level speculative-execution vulns but leaves three other firmware attack surfaces untouched: BIOS/UEFI firmware on the motherboard, Thunderbolt controller firmware (which lives on the TB chip, not the OS), and NVMe SSD firmware. Each of these has had publicly-disclosed vulnerabilities in the last five years, sits below the OS, and is invisible to every other hardening item in this document.

fwupd is the Linux Vendor Firmware Service (LVFS) client. Vendors publish signed firmware capsules to LVFS; fwupd fetches, verifies, and applies them. Works on Devuan because fwupd is dbus + udev based, not systemd-coupled.

Install and refresh metadata:

sudo apt install fwupd
sudo fwupdmgr refresh
sudo fwupdmgr get-devices

get-devices lists hardware that fwupd recognises, usually the system BIOS, Thunderbolt controllers, dock firmware where applicable, some NVMe drives, some external displays. Hardware not in the list either has no LVFS-published firmware (older or niche hardware) or isn’t supported by the running fwupd version.

Check for and apply updates:

sudo fwupdmgr get-updates
sudo fwupdmgr update

BIOS updates typically require a reboot and a brief firmware-side update phase. Don’t run BIOS updates from a laptop on battery without grid power: power loss mid-flash bricks the firmware chip. Tether to mains, charge to >50%, then update.

For hardware not in LVFS (older ThinkPads, anything pre-2018, some niche brands), check the vendor’s support page for a UEFI Capsule file. The flow is usually: download capsule, copy to ESP, boot to firmware setup, select “update from file,” follow vendor prompts. Lenovo, Dell, HP, and System76 all publish capsules this way for hardware that pre-dates their LVFS participation.

The Knots-lens / cold-storage operator practice diverges here: on signing machines that update rarely, firmware updates are deferred and applied on a manual schedule with verification (read the changelog, verify it doesn’t change firmware in unexpected ways). On a daily-driver online workstation, prompt firmware updates win because the attack window matters more than the deterministic-state property. This doc is the daily-driver case.

After firmware updates, take a fresh LUKS header backup (Part 2.1). Some firmware updates change how UEFI variables are read, which can change how the LUKS volume mounts, which is harmless until it isn’t.

3.4 Unattended security updates

apt upgrade discipline is part of every hardening guide ever written and people don’t run it because they forget. The fix is to make it automatic. unattended-upgrades from Debian (Devuan inherits) does this: runs apt update and applies security-only updates without prompting.

Critical detail: security-only, NOT all updates. Full unattended upgrades break things (a major version bump of a library can break userland; a kernel update can change boot behaviour). Security-only updates are scoped to the ${distro_codename}-security apt suite and ship strictly security fixes.

Install:

sudo apt install unattended-upgrades apt-listchanges

Configure:

sudoedit /etc/apt/apt.conf.d/50unattended-upgrades

The key section:

Unattended-Upgrade::Origins-Pattern {
    "origin=Devuan,codename=${distro_codename}-security";
};

Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::MinimalSteps "true";

Automatic-Reboot "false": never reboot without your knowledge. You notice when the system tells you a reboot is needed; you reboot at a moment that suits you.

apt-listchanges shows package changelogs in apt output so you can see what changed since the last upgrade. Configure to email or just display:

sudoedit /etc/apt/listchanges.conf

Set frontend=pager for interactive display, frontend=mail plus email_address= for email.

Enable the daily run:

sudo dpkg-reconfigure -plow unattended-upgrades

This prompts whether to enable automatic updates; answer yes.

Verify the next-day apt run:

cat /var/log/unattended-upgrades/unattended-upgrades.log

The Knots-lens divergence is worth flagging again: on signing machines and cold-storage adjacents, disable this entirely and update on a manual schedule with verification. The reasoning is that deterministic, manually-verified state matters more than promptness. For an online daily-driver workstation, the calculus flips: the attack window from a known unpatched CVE is the larger risk.

3.5 Kernel hardening

A short list of sysctl tweaks closes most local-privilege-escalation paths a generic Linux machine is exposed to. The standard set:

sudo tee /etc/sysctl.d/99-hardening.conf > /dev/null <<'EOF'
# Hide kernel pointers from non-root
kernel.kptr_restrict=2

# Restrict kernel log read to root only
kernel.dmesg_restrict=1

# Disable unprivileged eBPF (a steady source of local-priv-esc CVEs)
kernel.unprivileged_bpf_disabled=1
net.core.bpf_jit_harden=2

# Restrict ptrace to same-user only, no cross-user even with same UID
kernel.yama.ptrace_scope=2

# Protect against symlink/hardlink/FIFO/regular-file attacks in /tmp-style dirs
fs.protected_hardlinks=1
fs.protected_symlinks=1
fs.protected_fifos=2
fs.protected_regular=2

# Address space layout randomization at full strength
kernel.randomize_va_space=2

# Network hardening
net.ipv4.tcp_syncookies=1
net.ipv4.conf.all.rp_filter=1
net.ipv4.conf.default.rp_filter=1
net.ipv4.conf.all.accept_redirects=0
net.ipv6.conf.all.accept_redirects=0
net.ipv4.conf.default.accept_redirects=0
net.ipv6.conf.default.accept_redirects=0
net.ipv4.conf.all.send_redirects=0
net.ipv4.conf.default.send_redirects=0
net.ipv4.conf.all.accept_source_route=0
net.ipv6.conf.all.accept_source_route=0
net.ipv4.conf.all.log_martians=1
EOF

Apply without rebooting:

sudo sysctl --system

Verify a sample setting took:

sysctl kernel.kptr_restrict

Should return kernel.kptr_restrict = 2.

Trade-offs that may matter:

  • kernel.unprivileged_bpf_disabled=1 breaks eBPF tools (bpftrace, bcc-tools) when run as non-root. Workstation use is fine; if you actively use bpftrace as your user, run it under sudo or remove the line.
  • kernel.yama.ptrace_scope=2 blocks cross-user ptrace. Your own programs can still strace/gdb each other. Debugging other users’ processes requires sudo.
  • A kernel.unprivileged_userns_clone=0 line would tighten further but breaks Firefox’s sandbox, Flatpak, and rootless containers. Don’t add unless you run none of those.

Kernel command-line hardening parameters

The sysctls above are runtime kernel settings. The kernel command line sets boot-time parameters that aren’t reachable via sysctl: they alter how the kernel allocates memory, randomises layout, and exposes interfaces. Several are worth adding for a max-security configuration.

Edit /etc/default/grub:

sudoedit /etc/default/grub

Find GRUB_CMDLINE_LINUX_DEFAULT and extend it. The current line is typically GRUB_CMDLINE_LINUX_DEFAULT="quiet". The hardened form:

GRUB_CMDLINE_LINUX_DEFAULT="quiet slab_nomerge init_on_alloc=1 init_on_free=1 page_alloc.shuffle=1 vsyscall=none randomize_kstack_offset=on debugfs=off"

Update GRUB and reboot:

sudo update-grub
sudo reboot

What each parameter does:

  • slab_nomerge: prevents the kernel slab allocator from merging caches of identically-sized objects. Forces an attacker exploiting a slab vulnerability to target a specific cache rather than a merged one. Negligible perf cost.
  • init_on_alloc=1: zeroes kernel heap allocations at allocation time. Closes a class of uninitialised-memory information leaks. ~1-3% perf cost on most workloads.
  • init_on_free=1: zeroes memory at free time. Closes use-after-free information leaks. ~3-7% perf cost; combine with init_on_alloc.
  • page_alloc.shuffle=1: randomises free-list ordering in the page allocator. Mitigates heap-spray exploits. ~0% perf cost.
  • vsyscall=none: disables the legacy vsyscall mechanism (a small fixed-address syscall page from the early 2000s). Almost no modern userland still uses it; closes a ROP-gadget surface.
  • randomize_kstack_offset=on: adds per-syscall randomisation to the kernel stack offset. Mitigates kernel-stack-based exploits. ~1% perf cost.
  • debugfs=off: disables debugfs at boot. Closes an information-disclosure surface useful only when actively debugging the kernel.

Verify after reboot:

cat /proc/cmdline

Should show your full command line including the new parameters.

A heavier option for the truly paranoid: mitigations=auto,nosmt. This enables all CPU bug mitigations including disabling SMT (hyperthreading). SMT-off is the only way to fully close the L1TF and MDS class of side-channel attacks. The cost is a significant performance hit: roughly half of total CPU throughput on Intel and AMD chips that depend on SMT. Knots-lens / cold-storage operators on signing machines enable this without hesitation; daily-driver users typically don’t. Decision per-machine, per-workload.

If mitigations=auto,nosmt breaks something, fall back to mitigations=auto (full mitigations but keep SMT on).

3.6 AppArmor profiles

The Unix permission model assumes that processes run by your user are equally trusted with everything your user can read. Firefox running as you can read your SSH keys. AppArmor adds a second kernel-level permission system that confines individual programs to a declared subset of files, network endpoints, and Linux capabilities. An AppArmored Firefox cannot read ~/.ssh/, full stop, regardless of what an attacker controlling the browser tries to do.

Devuan ships AppArmor in the kernel. Confirm:

cat /sys/module/apparmor/parameters/enabled

Should report Y.

Install profiles and tooling:

sudo apt install apparmor apparmor-utils apparmor-profiles apparmor-profiles-extra
sudo aa-enabled

aa-enabled should report Yes. If not, you need to enable AppArmor on the kernel command line. Edit /etc/default/grub, find GRUB_CMDLINE_LINUX_DEFAULT, add apparmor=1 security=apparmor, then sudo update-grub and reboot.

List installed profiles:

sudo aa-status

The profiles directory is /etc/apparmor.d/. Profiles in there are either enforced (blocking), in complain mode (logging only), or disabled. Put profiles into complain mode first to find what they break before enforcing:

sudo aa-complain /etc/apparmor.d/usr.bin.firefox

Use Firefox for a while, watch the audit log:

sudo grep apparmor /var/log/kern.log

If nothing important breaks, switch to enforce:

sudo aa-enforce /etc/apparmor.d/usr.bin.firefox

Repeat per profile. Reasonable defaults to enforce: any browser profile, any chat client profile, any PDF reader profile, the man-page and command-line tool profiles in apparmor-profiles-extra.

Profile names vary by Devuan release. Don’t take any specific filename from this document as gospel; list what /etc/apparmor.d/ actually contains on your machine.

3.7 MAC address randomisation

Your network card’s MAC address is a hardware identifier visible on every network you join. Coffee shops, hotels, conferences, airports all log MACs into their captive-portal systems; the database is sold or breached often enough that “I was at this WiFi on this date” is a queryable fact about you years later. MAC randomisation breaks this linkage.

Devuan with ifupdown and no NetworkManager needs manual MAC randomisation via macchanger. Install:

sudo apt install macchanger

The installer asks whether to randomise MAC on every interface up. Answer yes.

For a per-interface hook that randomises on connection, create /etc/network/if-pre-up.d/random-mac:

sudo tee /etc/network/if-pre-up.d/random-mac > /dev/null <<'EOF'
#!/bin/sh
# Randomize MAC address before bringing the interface up.
# Skip loopback and any interface name starting with 'tun' or 'tap' (VPNs).
case "$IFACE" in
    lo|tun*|tap*) exit 0 ;;
esac
/usr/bin/macchanger -r "$IFACE" >/dev/null 2>&1 || true
EOF
sudo chmod +x /etc/network/if-pre-up.d/random-mac

If you use NetworkManager instead of ifupdown (some XFCE installs pull it in), it has its own MAC randomisation configuration. Create /etc/NetworkManager/conf.d/99-mac-randomize.conf:

sudo tee /etc/NetworkManager/conf.d/99-mac-randomize.conf > /dev/null <<'EOF'
[device-mac-randomization]
wifi.scan-rand-mac-address=yes

[connection-mac-randomization]
wifi.cloned-mac-address=random
ethernet.cloned-mac-address=random
EOF

To check which path applies on your machine, look for both processes:

pgrep -a NetworkManager
pgrep -a ifup

If NetworkManager is running, use the NetworkManager config. If only ifupdown is in play, use the if-pre-up.d script. On a default XFCE install with task-xfce-desktop, NetworkManager is typically the path; on a no-desktop install or after explicit removal, ifupdown alone is the path.

Restart networking. Verify with ip link show and look at the link/ether value; it should change between interface ups.

Trade-offs:

  • Networks that use MAC address whitelisting (some corporate WiFi, some captive portals that “remember” you) will reject the randomised MAC. Per-network exceptions need to use static MACs for those specific SSIDs.
  • A handful of WiFi adapters report MAC changes incorrectly. Test that connectivity actually works before relying on randomisation.

3.8 Encrypted DNS via dnscrypt-proxy

Every domain you resolve is visible to your DNS provider, which by default is your ISP or whatever the DHCP server hands out. ISPs sell that data. Captive portals manipulate it. A network attacker can spoof responses. Encrypted DNS closes those holes by sending DNS over an encrypted channel to a resolver you choose.

Check that nothing else is already bound to port 53:

sudo ss -tulnp | grep ':53 '

On a default Devuan with no resolved/dnsmasq, this returns nothing and you’re free to bind. If something is listed, identify and stop it before continuing (sudo service <name> stop && sudo update-rc.d <name> disable on sysvinit).

Install:

sudo apt install dnscrypt-proxy

Configure. The default /etc/dnscrypt-proxy/dnscrypt-proxy.toml is verbose. The minimum useful tweaks:

sudo nano /etc/dnscrypt-proxy/dnscrypt-proxy.toml

Find and set:

listen_addresses = ['127.0.0.1:53', '[::1]:53']
require_dnssec = true
require_nolog = true
require_nofilter = true

The require_nolog constraint filters to resolvers that publicly commit to not logging queries. The require_nofilter constraint filters out resolvers that block specific domains (the “family-friendly” filtering some public DNS services offer). The full list of public resolvers and their properties lives in /usr/share/dnscrypt-proxy/example-public-resolvers.md.

Start the service. The command depends on your init system:

# sysvinit
sudo service dnscrypt-proxy start
sudo update-rc.d dnscrypt-proxy enable

# OpenRC
sudo rc-service dnscrypt-proxy start
sudo rc-update add dnscrypt-proxy default

# runit
sudo ln -s /etc/sv/dnscrypt-proxy /var/service/

Point the system at the local resolver. Edit /etc/resolv.conf:

nameserver 127.0.0.1
options edns0

To prevent DHCP from overwriting this on every connect, on ifupdown:

sudo tee /etc/network/if-up.d/no-resolvconf > /dev/null <<'EOF'
#!/bin/sh
# Lock resolv.conf to local dnscrypt-proxy.
chattr -i /etc/resolv.conf 2>/dev/null || true
echo "nameserver 127.0.0.1" > /etc/resolv.conf
echo "options edns0" >> /etc/resolv.conf
chattr +i /etc/resolv.conf 2>/dev/null || true
EOF
sudo chmod +x /etc/network/if-up.d/no-resolvconf

Verify queries are going through the encrypted resolver. The log file location depends on the init system; check your dnscrypt-proxy.toml log_file setting or use the service’s logging mechanism:

dig +short example.com
# sysvinit (logging to /var/log/dnscrypt-proxy/ if enabled)
sudo tail -20 /var/log/dnscrypt-proxy/dnscrypt-proxy.log 2>/dev/null
# runit
sudo tail -20 /var/log/dnscrypt-proxy/current 2>/dev/null

The query should resolve. Then visit dnsleaktest.com and confirm the reported DNS server is the encrypted resolver, not your ISP.

3.9 USBGuard

USB is the largest privileged attack surface most laptops have. A malicious USB device (BadUSB, Rubber Ducky, USB Killer) plugged into a running machine can simulate keystrokes, mount a fake network adapter, or dump the bus. The same applies to malicious cables: O.MG-class cables (Hak5 / Mike Grover) embed a microcontroller plus a wireless radio in what looks like an ordinary USB-C or Lightning cable, present the attached peripheral’s HID descriptor on one channel, and silently inject keystrokes on another. USBGuard whitelists USB devices you actually use and blocks everything else; that whitelist covers cable-internal microcontrollers the same way it covers rogue peripherals, because both ultimately register as USB devices the kernel binds to.

Install:

sudo apt install usbguard

Before starting the service, generate a policy from currently-attached devices (so your existing peripherals don’t get blocked the moment you enable enforcement):

sudo usbguard generate-policy > /tmp/rules.conf
sudo mv /tmp/rules.conf /etc/usbguard/rules.conf
sudo chmod 600 /etc/usbguard/rules.conf

Review the rules. Each line is an allow (or block) for a specific device descriptor. Comment out any line for a device you don’t recognise. Confirm your keyboard, mouse, and built-in USB hubs are all in the allow list: getting locked out of a USB keyboard mid-session is a recoverable inconvenience but worth avoiding.

Start the service (commands match your init system, as in Part 3.8).

Test by plugging in a USB stick you haven’t whitelisted. It should be blocked. Check the policy:

sudo usbguard list-devices

Devices in state block are not allowed. To temporarily allow a new device this session:

sudo usbguard allow-device <device-id>

To allow permanently, add an allow rule for it. The friendliest workflow: keep the policy file in your dotfiles repo (encrypted), edit it manually when adding new permanent peripherals.

Trade-off: you will get blocked at least once by your own USBGuard config when you plug in a colleague’s stick or a new printer. The fix is one command. The threat model where this matters: a hostile peripheral plugged into a logged-in machine; physical attacks on a powered-on laptop; supply-chain attacks via “free” USB sticks or cables from untrusted sources.

3.10 Thunderbolt and DMA hardening

Thunderbolt is PCIe-over-cable. A malicious Thunderbolt device gets the same kind of DMA access to memory that an internal PCIe card would, beneath the USB descriptor layer that USBGuard enforces. Thunderspy (Björn Ruytenberg, 2020) showed Thunderbolt 1, 2, and 3 are fundamentally vulnerable to attacks against an unlocked machine where the attacker can plug something into a Thunderbolt port. The practical defences are Kernel DMA Protection (Linux 5.x and later, requires IOMMU configured and turned on) plus an explicit per-device authorisation model so unknown devices are blocked until you say otherwise. Thunderbolt 4 hardware mandates Kernel DMA Protection by default; Thunderbolt 3 hardware does not. Hardware choice matters here; Choosing the hardware covers the IOMMU/Kernel-DMA-Protection state across current laptop options.

If you do not use Thunderbolt at all, disable it in BIOS. This is the cleanest defence and ends the section.

If you do use Thunderbolt, three things together:

Enable IOMMU at the kernel command line. Without IOMMU, DMA protection has nothing to enforce. Edit /etc/default/grub:

sudo nano /etc/default/grub

For Intel, append to GRUB_CMDLINE_LINUX_DEFAULT:

intel_iommu=on iommu=pt

For AMD, append:

amd_iommu=on iommu=pt

The iommu=pt (passthrough) mode is the performance-friendly default: devices that aren’t actively isolated use direct mapping. Update GRUB and reboot:

sudo update-grub
sudo reboot

Verify:

dmesg | grep -i -E "iommu|dmar"

On Intel you should see DMAR: IOMMU enabled plus per-device enable messages. On AMD you should see AMD-Vi: AMD IOMMUv2 loaded or AMD-Vi: Found IOMMU at 0000:00:00.2. If dmesg shows nothing matching, IOMMU isn’t on; check BIOS for an “Intel VT-d” or “AMD-Vi” / “IOMMU” setting and enable it there first, then re-verify after reboot.

Set Thunderbolt security level in firmware. Most laptop BIOSes expose a Thunderbolt security level setting: SL0 (none, allow everything), SL1 (user authorisation required), SL2 (require signed firmware), SL3 (DisplayPort-only, no PCIe). Set it to at least SL1. SL2 if your hardware supports it. SL3 if you only ever use Thunderbolt to drive an external monitor.

Install bolt for per-device authorisation at runtime.

sudo apt install bolt

The bolt package provides boltd (the daemon) and boltctl (the CLI). It works on Devuan because it speaks to udev and dbus rather than depending on systemd directly; on Devuan with elogind installed (the default on the XFCE flavour) bolt detects sessions correctly. List currently-attached Thunderbolt devices:

boltctl list

When a new Thunderbolt device is plugged in, it shows up in the authorized: no state. Authorise it for this boot only:

boltctl authorize <uuid>

Authorise and remember it across reboots:

boltctl enroll <uuid>

Inspect a specific device:

boltctl info <uuid>

Forget an enrolled device (a previously-trusted dock you want to revoke):

boltctl forget <uuid>

Trade-offs. First time you plug a Thunderbolt dock, eGPU, or external storage, it requires the enrol step; until you do it the device shows up but doesn’t function. If boltd isn’t running, the kernel falls back to whatever the BIOS security level allows, so confirm the service is active (pgrep -a boltd). Some old laptops have firmware that claims IOMMU is enabled when it isn’t; dmesg is the ground truth, not the BIOS setting screen. Thunderbolt 4 hardware gets Kernel DMA Protection automatically, but the per-device authorisation model is still worth using.

The threat model where this matters: an unattended logged-in machine left in a hotel room or a conference venue; a “found” docking station offered for charging; a TB-equipped peripheral handed over by a stranger. Pre-Thunderbolt-4 hardware where the IOMMU isn’t actually enabled is wide open even with boltd running, because DMA protection has nothing to enforce.

3.11 Host firewall with nftables

A workstation on a shared network is by default reachable on every port for which something is listening. The sysctl tightening in Part 3.5 closes some network-level issues; an active firewall closes the rest. Devuan ships nftables in modern releases; iptables is a legacy alternative for the same job.

Install:

sudo apt install nftables

Default-deny inbound, allow outbound, allow established and related (the standard workstation policy). Create /etc/nftables.conf:

sudo tee /etc/nftables.conf > /dev/null <<'EOF'
#!/usr/sbin/nft -f
flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # Allow already-established connections and related (e.g. ICMP responses)
        ct state established,related accept
        ct state invalid drop

        # Allow loopback
        iif lo accept

        # Allow ICMP (ping, traceroute, MTU discovery)
        ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept limit rate 4/second
        ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept

        # Allow DHCP responses (if you use DHCP)
        udp sport 67 udp dport 68 accept

        # mDNS (Avahi, for local network discovery) — comment out if you don't use it
        # ip daddr 224.0.0.251 udp dport 5353 accept
        # ip6 daddr ff02::fb udp dport 5353 accept

        # Log and drop everything else (sample 1 in 50 to keep logs sane)
        limit rate 10/minute log prefix "nft_input_drop: " level info
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}
EOF
sudo chmod 600 /etc/nftables.conf

Apply and enable:

sudo nft -f /etc/nftables.conf
sudo service nftables start
sudo update-rc.d nftables enable    # sysvinit
# OpenRC: sudo rc-service nftables start; sudo rc-update add nftables default
# runit:  sudo ln -s /etc/sv/nftables /var/service/

Verify the ruleset loaded:

sudo nft list ruleset

You should see the rules above. Drop logs go to syslog (sysvinit) or to the journal where present.

Add per-application allow rules only when you need them. Examples:

# Allow SSH from the local network only (don't open to the internet)
ip saddr 192.168.0.0/16 tcp dport 22 accept

# Allow Syncthing peer connections
tcp dport 22000 accept
udp dport 22000 accept

# Allow KDE Connect
tcp dport 1714-1764 accept
udp dport 1714-1764 accept

Trade-offs and notes:

  • If you run any locally-listening service that a remote machine needs to reach (file sharing, a self-hosted dev server, an audio jack server), you’ll add an allow rule for it. Default-deny means new services start invisible from the network until you whitelist them, which is what you want.
  • If you run a VPN client (WireGuard, OpenVPN), the VPN tunnel interface (typically wg0, tun0) needs iif wg0 accept (or equivalent for your interface name) in the input chain to allow VPN-side traffic in.
  • This is a workstation policy. Servers, routers, and machines that need to forward traffic need different rule shapes; the forward chain stays drop here and that’s correct for a workstation.
  • After making changes to /etc/nftables.conf, run sudo nft -f /etc/nftables.conf to apply. To test before persisting, use sudo nft -c -f /etc/nftables.conf (dry-run; -c is check-only).

3.12 Kernel lockdown mode

Kernel lockdown prevents even root from loading unsigned kernel modules, writing to /dev/mem, modifying running kernel state, or kexec’ing a custom kernel. It closes the post-compromise rootkit vector: once an attacker has root on a lockdown-enforced system, they cannot install a kernel-level rootkit without breaking out of lockdown first.

This is the most disruptive item in this document. Read the trade-offs before enabling.

Edit /etc/default/grub:

sudo nano /etc/default/grub

Find:

GRUB_CMDLINE_LINUX_DEFAULT="quiet"

Change to:

GRUB_CMDLINE_LINUX_DEFAULT="quiet lockdown=confidentiality"

Update GRUB and reboot:

sudo update-grub
sudo reboot

Verify after reboot:

cat /sys/kernel/security/lockdown

Should show none integrity [confidentiality] with the active mode bracketed.

Trade-offs:

  • Hibernation requires signed kernel images and is incompatible with lockdown=confidentiality on most distros. If you sized swap to enable hibernation (Part 1.3), test that resume works after enabling lockdown; if it doesn’t, fall back to lockdown=integrity or remove the parameter entirely.
  • DKMS modules (NVIDIA proprietary drivers, ZFS-on-Linux, VirtualBox host modules) need to be signed by your own Machine Owner Key (MOK) to load under lockdown. Configuration is more work; consult the Devuan secure-boot/DKMS docs.
  • The fallback is lockdown=integrity, which still blocks kernel modification but allows reading kernel memory. Less protection but more compatibility.
  • If something breaks unrecoverably, edit GRUB at boot (you set a GRUB password in Part 2.2, so this requires the password) and remove lockdown=confidentiality from the kernel line for one boot.

Part 4: Architectural upgrades

These items move the threat model. Part 4.1 (TPM2-sealed unlock) detects firmware/bootloader tampering after the fact; Part 4.2 (/boot on USB) closes the evil-maid surface that exists on the laptop’s internal disk; Part 4.3 (Heads / coreboot) replaces the firmware itself with measured, open firmware; Part 4.5 (VM compartmentalisation) closes the cross-application blast radius that single-user hardening cannot; Part 4.6 (TPM2 PIN-binding) extends Part 4.1 to require user presence at unlock. Together they address the residual threats that Parts 1-3 leave open.

The Knots-lens / cold-storage operator convergence is that any threat model genuinely worried about physical access to the powered-off machine includes Part 4.2 (carry your bootloader) at minimum. The most paranoid operators also run Part 4.3 (Heads). Part 4.1 (with the 4.6 PIN extension for laptops) is useful but is not a substitute for either.

4.1 TPM2-sealed unlock with PCR binding

A TPM2 chip on the motherboard can hold an unlock secret released only when the system’s firmware and bootloader measurements match a previously-recorded state. With this enrolled, the LUKS unlock happens without a passphrase prompt at boot, and any tampering with the firmware or bootloader (the evil-maid case) breaks the unlock.

The trade-off: when you legitimately update the firmware or kernel or GRUB, the PCR measurements change, the TPM refuses to release the secret, the system falls back to passphrase prompt. This is the correct behaviour. It is also the behaviour that bites people who update their firmware and then can’t remember the LUKS passphrase because they’ve been TPM-unlocking for months.

This procedure is not in this document, on purpose. It’s not because it’s bad (it’s a real security improvement) but because the recovery story matters more than the install story, and the recovery story varies by hardware and firmware vendor. Before you enrol TPM2 unlock:

  • Verify the LUKS passphrase still works. Power off, power on, type the passphrase. If it doesn’t work, the LUKS passphrase is already broken and TPM enrolment will silently mask the problem.
  • Have at least two independent LUKS header backups (Part 2.1).
  • Confirm a recovery USB boots and can decrypt the disk manually with the passphrase.

The actual enrolment is one command (clevis-luks-bind -d /dev/<luks-part> tpm2 '{"pcr_ids":"7"}' on Devuan via clevis-luks and clevis-tpm2; systemd-cryptenroll --tpm2-device=auto exists but Devuan’s no-systemd default makes clevis the natural path). Both tools have good upstream documentation that’s better than what would fit here. Read theirs before enrolling.

A note on what’s coming. GRUB 2.14 (January 2026) introduces a native TPM2 key protector that performs the unlock at the bootloader stage rather than relying on a userspace tool like clevis. Excalibur’s GRUB 2.12 doesn’t have it; this section describes the clevis-based path that works on the current Devuan. When Devuan Freia ships, the GRUB-native path will be the cleaner option and clevis-on-Devuan will be the fallback for retrofitting older installs.

Important interaction: if you do Part 4.2 (/boot on USB), the TPM2 approach here becomes lower-priority because the same threat (boot-chain tampering) is closed differently. Don’t stack both unless you understand what each adds.

4.2 Separate USB key for /boot

Recommended for any threat model that includes physical access to the powered-off machine. This was previously framed as optional. On the security-and-durability axis, it isn’t: it’s the single highest-leverage architectural change available within the project’s threat model.

What this gets you:

  1. The laptop without the USB stick is unbootable. A thief who steals the laptop without your keychain gets a brick; you don’t have to assume they got both.
  2. With /boot no longer needing to be readable by GRUB on the internal LUKS volume, you can re-format the internal LUKS with Argon2id key derivation instead of PBKDF2. Argon2id is memory-hard and GPU-resistant in a way PBKDF2 is not; this is the real KDF answer, not a workaround.
  3. The ESP-tampering evil-maid surface moves off the laptop’s internal disk. An attacker who gets physical access to the laptop alone cannot modify the bootloader because the bootloader isn’t there.

What you trade:

  • A USB stick to carry. Lose it and the laptop is unbootable until you reconstruct /boot from the recovery USB.
  • A duplicate USB stored separately, generated at the same time, kept current with firmware/kernel updates. You don’t want the duplicate to be three GRUB versions behind when you need it.

Procedure in concept: move /boot contents to a small FAT/ext4 partition on a USB stick, update /etc/fstab and /etc/crypttab, reinstall GRUB to the USB ESP, re-encrypt the internal LUKS with Argon2id (cryptsetup luksConvertKey --pbkdf argon2id). The failure modes are real (lose both USBs, lose access until reconstruction from backups), which is why two USBs is the operational minimum.

If you do this, redo the LUKS header backup (Part 2.1) with the new Argon2id parameters; the old header is no longer valid for unlocking the re-formatted volume.

The detailed procedure is a multi-section walkthrough that would double this document’s length; the Knots-lens reference setup for it lives in the Bitcoin Knots / OpenTimestamps / Tails-developer communities, where the carry-the-bootloader pattern is standard practice. A separate companion guide for this is planned; until then, the upstream pointers are: Arch Wiki’s “dm-crypt/Encrypting an entire system” with /boot on USB variant, and Tails Project’s documentation on separated boot media.

4.3 Heads / coreboot

The layer below everything in this document. UEFI firmware loading GRUB (whether GRUB lives on the internal ESP per Part 1-3 or on a USB key per Part 4.2) is itself a black box you cannot audit. Heads is an open-source boot firmware based on coreboot that measures itself plus the kernel and initramfs into a TPM, verifies signatures, and refuses to proceed if anything was tampered with. The cleartext-on-the-laptop equivalent moves from “the ESP on internal storage” to “the SPI flash chip on the motherboard,” which is a different physical chip than the SSD/NVMe and requires specialised hardware (SOIC clips, an external flasher) to modify undetectably.

Heads is hardware-specific. Supported hardware as of 2026 includes the ThinkPad X230, T430, T530, W530, X230T (the classic xx20/xx30-generation coreboot-friendly ThinkPad lineage); the Purism Librem laptops; the System76 laptops with their open firmware; the Dasharo-supported NovaCustom and MSI boards. Newer ThinkPads (post-2015 generally) have signed-firmware lockdowns that make coreboot installation harder or impossible without hardware modification. Haswell-generation ThinkPads (xx40 / W541 / T440p) have coreboot mainboard support but Heads payloads on those boards are less mature and the community support is thinner; treat them as research-grade rather than turnkey.

Why this matters for the Knots-lens / cold-storage operator threat model: every other item in this document assumes the firmware loading your OS is benign. If the firmware itself is compromised (BIOS rootkit, supply-chain interception, evil maid with a flasher), nothing in Parts 1-3 helps. Heads is the layer that makes that assumption checkable. It’s not in this document as a procedure because hardware-specific install procedures don’t generalise. It’s named here, in Part 4, because the priority ordering for long-term security is: backups (3.1) → bootloader on USB (4.2) → Heads (4.3). Heads is the third leg.

If you’re going to buy or repurpose hardware deliberately for this setup, Heads compatibility is the constraint to plan around. The classic answer is a used ThinkPad X230 or T430 in good condition; the modern answer is a Dasharo-supported board if you can afford new hardware. Documentation lives at osresearch.net (Heads) and dasharo.com.

Haswell-generation ThinkPads (xx40 / T440p / X240 / T540p / W541). This generation has coreboot mainboard support but the Heads payload on these boards is in a different state than on xx30 hardware. The T440p has the most active community work (the T440p-coreboot hackathon project has produced working images), and the X240 is reasonably documented. W541 and T540p have mainboard support but smaller user communities and rougher payload integration. The functional difference compared to X230/T430: install procedure is longer (often requires reflashing the embedded controller separately), the keyboard situation is contentious (the xx40 series shipped with a chiclet keyboard that many users replace with the older 7-row layout via aftermarket parts), and you’re closer to “research user contributing back” than “stable user just running it.” If you specifically want Haswell-generation hardware with Heads, plan on a weekend of setup work rather than the few hours that xx30 takes. The threat-model payoff is the same as xx30 (verified-boot via TPM, tamper-evident boot, modifiable everything); the per-user cost is higher.

Pre-flashed Heads as an alternative to flashing yourself. Nitrokey sells refurbished ThinkPads (currently X230, T430, X1 Carbon) with Heads pre-installed and a Nitrokey USB token paired to the laptop for boot attestation: green/red LED indicating whether the boot-chain measurement matches what was enrolled. This is the “I want Heads working without learning to use an SOIC clip” path. Covered in Choosing the hardware under the NitroPad subsection. Hardware is xx30-generation so the Heads payload is mature; you pay for Nitrokey’s flashing labour and QA rather than doing it yourself.

Alternative for users not committing to Heads: Secure Boot with custom keys. Devuan ships unsigned GRUB so the stock setup runs with Secure Boot off (named in the Architectural costs subsection of Part 1). Users who want to close the evil-maid ESP-tampering surface without going to Heads can replace Microsoft’s default Secure Boot keys with their own, then sign their own GRUB and kernel images. The firmware will then refuse to load any boot artifact not signed by your key. Two tools cover this on Debian-family systems: shim-signed plus a custom Machine Owner Key enrolled via mokutil, or the sbctl userspace tool that handles key generation, enrolment, and signing in one workflow. The cost compared to Heads: the firmware itself remains a black box, only the boot chain above the firmware is verified, so an attacker who can modify the firmware (CMOS clear plus EEPROM write, supply-chain interception) defeats this. Heads checks the firmware itself; Secure-Boot-with-custom-keys checks only what the firmware loads. For supply-chain or nation-state threat models, Heads remains the answer. For lost-laptop or opportunistic-physical-access models, Secure Boot with custom keys is a meaningful step up from stock with much lower setup cost than Heads.

4.4 Btrfs root at next reinstall

The install guide uses ext4 because it’s the conservative default. Btrfs offers snapshots (instant point-in-time copies of the filesystem state, useful for “undo this kernel upgrade”) and built-in compression. The cost: more complexity, occasional historical reports of corruption that ext4 doesn’t have, and a different mental model.

If you’re going to switch, do it at next reinstall rather than converting in place. Use mkfs.btrfs in place of the guide’s mkfs.ext4 steps and change the fstab options to use Btrfs with subvolumes for @, @home, @var, etc. The snapper package handles automated snapshot lifecycle. The detailed procedure is too long for this guide; consult upstream Btrfs documentation.

4.5 VM compartmentalisation for Tor-routed work

When you need a Tor-anonymised workspace as a regular thing (research, source communication, anonymous accounts, IP-sensitive operations), running Tor or Tor Browser directly on the hardened host is the wrong answer. Non-browser apps still see the real network, browser fingerprinting bleeds across contexts, and one misconfigured app leaks the real IP. The right answer is Whonix: two VMs (Gateway plus Workstation) where the Workstation has no network interface other than the Gateway’s internal NIC. The Workstation literally cannot see your real IP: by architecture, not by configuration discipline.

There are three isolation rungs. This section is the procedure for the recommended rung; the other two are referenced for completeness.

  1. Whonix VMs under your daily user account. Full process isolation (VMs are separate kernels), full network isolation (Whonix architectural guarantee). Trust boundary: the Devuan host kernel plus KVM. If the host is compromised, both VMs are compromised. The working default.
  2. Whonix VMs under a separate Linux user account. Adds a second boundary: if your daily-driver browser gets exploited, the attacker is in user1 and cannot read user2’s VM images, libvirt sockets, or running VM memory. Conversely if the Workstation is popped and the attacker manages a guest-to-host escape, they land in user2 with no access to your daily files. The configuration this section walks through.
  3. Whonix VMs under Qubes. The maximalist configuration. Out of scope for this document: see Qubes OS for the Qubes-Whonix entry. If your threat model supports Qubes, do Qubes-Whonix on Heads-coreboot hardware rather than the procedure below.

What rung 2 gets you that rung 1 doesn’t:

  • X11 process isolation. X11 has no inter-client isolation within a single X server: any X client can keylog any other, screen-grab any other, and inject keystroke events. Running virt-manager as the same user as your daily browser means a popped browser can spy on the VM management UI and the Workstation console. Running it as a separate user on a separate X server (separate VT, fresh greeter login) gives you real isolation. Same X server with su or sudo -i does not isolate: the new process inherits $DISPLAY and is just another client on the same server.
  • libvirtd socket access. The system libvirt socket (/var/run/libvirt/libvirt-sock) is restricted to the libvirt group. If only the security user is in that group, the daily user cannot list, manage, or interact with the VMs at all.
  • VM disk file permissions. qcow2 files in /var/lib/libvirt/images/ are owned by libvirt-qemu and group-readable by the libvirt group. Daily user not in libvirt: cannot read the disk image at rest.
  • Running VM memory. QEMU runs as libvirt-qemu, not as either of your users. Neither user can ptrace or /proc/PID/mem-read a running VM. The libvirt group membership is the only userspace handle on management; reading VM memory directly requires kernel-level escape.

What rung 2 trades:

  • Two accounts to maintain. Updates, configuration drift, two sets of dotfiles.
  • One switch-user step per anonymised session via LightDM’s “Switch User” (or dm-tool switch-to-greeter from the command line).
  • The Devuan host kernel is still the shared trust boundary. A kernel exploit or DMA attack against the host defeats both rungs equally. If that’s your threat model, the answer is Heads plus Qubes-Whonix, not this.

Install KVM and libvirt

Run at root:

sudo apt update
sudo apt install --no-install-recommends \
  qemu-system-x86 qemu-utils \
  libvirt-daemon-system libvirt-clients \
  virt-manager gir1.2-spiceclientgtk-3.0 \
  dnsmasq

--no-install-recommends keeps the closure tight. The Whonix wiki’s KVM install page uses the same dependency set under the Debian alias qemu-kvm (which is a transitional package pointing at qemu-system-x86).

Start libvirtd. Devuan default is sysvinit; adjust for openrc/runit:

# sysvinit
sudo service libvirtd start
sudo update-rc.d libvirtd defaults

# OpenRC: sudo rc-service libvirtd start && sudo rc-update add libvirtd default
# runit:  sudo ln -s /etc/sv/libvirtd /var/service/

Create the security user

sudo adduser secuser
sudo chmod 700 /home/secuser
sudo adduser secuser libvirt
sudo adduser secuser kvm

The whole point of the second account is that libvirt management is scoped to it. Do not add your daily user to libvirt or kvm. If your daily user is already in those groups from a previous setup:

sudo deluser <daily-user> libvirt
sudo deluser <daily-user> kvm

Confirm:

groups <daily-user>   # libvirt and kvm should be absent
groups secuser        # libvirt and kvm should be present

Group changes require a logout/login to take effect.

USBGuard rule for the security user

Part 3.9 enables USBGuard with a default-deny posture for unknown devices. If you plan to pass USB devices into a Whonix VM (a hardware token, an audio interface, a specific USB stick for file transfer), the device must be allowlisted on the host before libvirt can pass it through. This is intentional: USBGuard is the first line; libvirt’s device passthrough is the second.

Allow your device by adding to /etc/usbguard/rules.conf:

allow id 1050:0407 name "YubiKey 5" serial "..." via-port "1-2"

Replace VID:PID, name, serial, and via-port with what usbguard list-devices reports for your device. Reload:

sudo service usbguard restart

Download and verify Whonix images

Log out of your daily user. Log in as secuser via the LightDM greeter. From secuser’s session, do the download in secuser’s home directory, not as the daily user with a cp across, since the daily user’s downloads directory is outside the trust boundary you just established.

Get the current KVM image and signature from https://www.whonix.org/wiki/KVM. The current release at time of writing is 18.1.6.4 (April 2026); check the wiki for the version of the moment.

cd ~
wget https://download.whonix.org/libvirt/<current>/Whonix-XFCE-<version>.Intel_AMD64.qcow2.libvirt.xz
wget https://download.whonix.org/libvirt/<current>/Whonix-XFCE-<version>.Intel_AMD64.qcow2.libvirt.xz.asc

Verify the signature. The Whonix project’s signing key is Patrick Schleizer’s; fetch and verify the fingerprint per the procedure at https://www.whonix.org/wiki/Whonix_Signing_Key. Do not skip this step: the .libvirt.xz file is a multi-gigabyte blob from which you will be running code with kernel privileges inside the guest.

# Per the procedure on whonix.org/wiki/Whonix_Signing_Key:
gpg --import patrick.asc
gpg --fingerprint <key-id>   # confirm against the fingerprint published on whonix.org
gpg --verify Whonix-XFCE-*.libvirt.xz.asc Whonix-XFCE-*.libvirt.xz

The output must say “Good signature” and the fingerprint must match the one published on the Whonix Signing Key wiki page. Anything else: stop and re-download from a different mirror.

Extract:

tar -xvf Whonix-XFCE-*.libvirt.xz

This produces two qcow2 images (Whonix-Gateway-*.qcow2, Whonix-Workstation-*.qcow2), two network XML files (Whonix_external*.xml, Whonix_internal*.xml), and two VM definition XML files (Whonix-Gateway*.xml, Whonix-Workstation*.xml).

Import into libvirt

The Whonix images need to live where libvirt-qemu can read them. Default pool is /var/lib/libvirt/images/:

sudo mv Whonix-Gateway-*.qcow2 /var/lib/libvirt/images/
sudo mv Whonix-Workstation-*.qcow2 /var/lib/libvirt/images/
sudo chown libvirt-qemu:libvirt-qemu /var/lib/libvirt/images/Whonix-*.qcow2
sudo chmod 600 /var/lib/libvirt/images/Whonix-*.qcow2

Define and start the two networks:

virsh -c qemu:///system net-define Whonix_external*.xml
virsh -c qemu:///system net-define Whonix_internal*.xml
virsh -c qemu:///system net-autostart Whonix-External
virsh -c qemu:///system net-start Whonix-External
virsh -c qemu:///system net-autostart Whonix-Internal
virsh -c qemu:///system net-start Whonix-Internal

Whonix-External is a NAT’d network connecting the Gateway to the host’s outbound interface. Whonix-Internal is an isolated network between Gateway and Workstation: no route to the host, no DHCP from libvirt’s dnsmasq, no path to the internet except through the Gateway’s Tor process. This is the architectural property that makes Whonix’s IP-leak resistance hold.

Define and start the VMs (Gateway first; Workstation will not get network until Gateway is up):

virsh -c qemu:///system define Whonix-Gateway*.xml
virsh -c qemu:///system define Whonix-Workstation*.xml
virsh -c qemu:///system start Whonix-Gateway
virsh -c qemu:///system start Whonix-Workstation

First boot will prompt you to accept the Whonix licence, choose persistent or live mode, and run upgrade-nonroot to apply outstanding security updates. Follow the on-screen prompts. To make the VMs start automatically when secuser logs in:

virsh -c qemu:///system autostart Whonix-Gateway
virsh -c qemu:///system autostart Whonix-Workstation

Interactions with the rest of this doc

AppArmor (Part 3.6). The libvirt-daemon-system package installs an AppArmor profile for libvirtd and dynamically generates a per-VM profile (libvirt-<uuid>) when each VM starts. These work with your existing AppArmor setup without configuration changes. Run sudo aa-status after first VM start; the dynamic profiles appear in the enforced list. That is the desired state.

Encrypted DNS (Part 3.8). dnscrypt-proxy on the host runs on 127.0.0.1:53 and the host’s resolv.conf points there. The Whonix Gateway runs its own DNS (Tor’s DNSPort) and the Workstation queries the Gateway: neither VM touches the host’s dnscrypt-proxy. This is correct: you do not want your Tor-routed DNS lookups going through your clearnet resolver even when that resolver is encrypted.

MAC randomisation (Part 3.7). libvirt generates MAC addresses for each VM’s virtual NIC; these are stable per-VM by default and serve as VM identifiers, not real hardware addresses. The host’s physical-NIC randomisation continues to work; the VMs do not see the host’s MAC.

USBGuard (Part 3.9). Covered above. The host’s USBGuard policy is the gatekeeper; libvirt’s USB passthrough cannot pass a device that USBGuard has blocked.

Thunderbolt and IOMMU (Part 3.10). VT-d / AMD-Vi is already enabled per Part 3.10. KVM uses IOMMU for safe device passthrough; no additional configuration. If you do PCI passthrough of a discrete device into a VM (rare for Whonix; common for graphics-accelerated VMs), the IOMMU groups from Part 3.10 are what you’ll be working against.

nftables (Part 3.11). libvirt manages its own iptables/nft rules for virbr0 (the default NAT bridge) and for the Whonix-External NAT. These rules live in libvirt-managed chains and do not conflict with the default-deny INPUT chain from Part 3.11. Confirm with sudo nft list ruleset | grep libvirt after libvirtd starts. If your Part 3.11 ruleset sets the FORWARD chain to default-drop, allow libvirt’s forward chain explicitly or set FORWARD to accept and let libvirt’s per-network rules do the filtering.

Kernel lockdown (Part 3.12). If you enabled Part 3.12, KVM’s kernel module loading is unaffected (modules are signed by the distribution kernel build). Custom out-of-tree KVM modules would need MOK signing per Part 3.12’s DKMS note.

Daily workflow

To use the anonymous workspace from your daily session: open XFCE’s user menu and select “Switch User” (which calls LightDM), or from a terminal:

dm-tool switch-to-greeter

Log in as secuser from the greeter. This spawns a fresh X server on a fresh VT. If autostart is configured, the Whonix VMs come up at login; otherwise:

virsh -c qemu:///system start Whonix-Gateway
virsh -c qemu:///system start Whonix-Workstation
virt-manager

To return to your daily session: shut the VMs cleanly first (virsh -c qemu:///system shutdown Whonix-Workstation, then Gateway), log out of secuser, which kills its X session. Switch back to your daily session via the LightDM greeter or the appropriate VT (typically Ctrl+Alt+F7 or F8 depending on slot).

Do not use su - secuser or sudo -u secuser virt-manager from your daily session. That puts virt-manager on your daily X server with $DISPLAY inherited, defeating the X11 isolation that was the entire point of the separate account.

What this configuration does not defend against

  • Kernel exploits and DMA attacks against the Devuan host. Both rung 1 and rung 2 share the host kernel; either gets you fully owned by a host-kernel exploit. If this is your threat model: Heads coreboot plus Qubes-Whonix, not Devuan.
  • Operational deanonymisation. Logging into a real-name account from the Whonix Workstation. Reusing usernames or browser fingerprints across personas. Posting on a schedule that correlates with your identified accounts. The OS does not save you from yourself; this is the layer no software can provide.
  • Compromised Whonix image at download. Signature verification per the procedure above covers the case of a tampered file at the mirror. It does not cover the case where the signing key itself is compromised or the Whonix project is compelled to sign a backdoored image. Use the Tor-routed download mirror from the Whonix wiki for that, and pin the signing key once you have it.
  • Side channels between guest and host. Spectre-class CPU side-channels can leak across VM boundaries. Microcode (Part 3.2) and mitigations=auto,nosmt on the kernel command line (Part 3.5) reduce the surface; they do not close it. For threat models that include side-channel attackers, the answer is physically separate hardware, not VMs on shared hardware.

4.6 TPM2 PIN-binding

Plain TPM2-sealed unlock (Part 4.1) ties the LUKS unlock to PCR measurements of the boot chain: the disk decrypts automatically if the boot chain looks unchanged. This solves the “passphrase prompt at boot” usability problem but introduces a different failure: an attacker who steals the laptop and just boots it gets the disk decrypted as far as the login screen. PCR-binding alone does not require the legitimate user to be present; it only requires the legitimate boot chain.

TPM2 PIN-binding adds a typed PIN to the unlock condition. The TPM releases the LUKS key only if BOTH the PCR measurements match AND the user types the correct PIN within a small number of attempts. The PIN is rate-limited at the TPM level (typically 32 attempts before lockout, with the count persisting across reboots), so brute-forcing the PIN through the TPM is infeasible even with weak PINs. A six-digit PIN is sufficient against the TPM’s rate-limiting; an eight-digit PIN is overkill.

Configuration with systemd-cryptenroll (available on Devuan via systemd-utilities, even though Devuan doesn’t use systemd as init):

# enroll the TPM2 with both PCR binding and PIN
sudo systemd-cryptenroll \
    --tpm2-device=auto \
    --tpm2-pcrs=0+1+2+3+7 \
    --tpm2-with-pin=yes \
    /dev/nvme0n1p2

# remove the previous TPM2 entry (without PIN) if you had one
sudo systemd-cryptenroll --wipe-slot=tpm2 /dev/nvme0n1p2
# then re-enroll with PIN per above

At boot, the initramfs prompts for the PIN. If the PIN matches AND the PCR state matches, the TPM releases the LUKS unlock key and the boot continues. If either condition fails, the boot falls through to the passphrase fallback (your original LUKS passphrase from the install script), so you’re not locked out: you’ve just lost the convenience.

When PIN-binding makes sense:

  • Laptop you carry. Loss/theft is part of the threat model. The TPM-only setup gives an attacker the disk; TPM+PIN forces the attacker to either know the PIN, brute-force through TPM rate-limiting (infeasible), or fall back to attacking the LUKS passphrase directly (which they could have done without TPM2 anyway).
  • You want the TPM2 convenience but not the security regression of plain TPM2.

When plain TPM2 (4.1) without PIN is enough:

  • Desktop in a physically secured space (locked office, secured home), so theft-while-running is not the threat model. The TPM-only setup gives convenience for the legitimate user; the threat model assumes the attacker doesn’t get the physical machine.
  • The threat model worries about disk theft (the disk is removed from the machine), not whole-machine theft. Plain TPM2 covers this: the disk alone won’t decrypt without the TPM it was paired with.

Pick PIN-binding for laptops, plain TPM2 only for physically secured desktops. The PIN prompt at boot is faster than the LUKS passphrase prompt (the PIN is much shorter than a LUKS passphrase by design) so the usability hit is modest. PCR list 0+1+2+3+7 covers firmware, option ROMs, kernel measurements, and Secure Boot policy: adjust based on what you want the boot chain to be sensitive to.

A note on Devuan specifically: systemd-cryptenroll lives in the systemd-utilities package and works without systemd as init, since the cryptenroll tool is a userspace utility that just talks to the TPM and writes LUKS metadata. The initramfs side, where the TPM is queried at boot, uses the tpm2-tools package and a custom unlock script (systemd’s systemd-cryptsetup is not available without systemd as init). The Arch Wiki’s dm-crypt page documents the manual initramfs hook required. This is the kind of work that’s “doable but not turnkey” on Devuan: the kernel support is fine, the tooling exists, the initramfs glue requires manual setup.

Part 5: Recovery from failed boot

When something breaks, the recovery path is: boot from the install USB in live mode, manually decrypt the LUKS volume, mount it, chroot in, fix the thing.

# Live USB shell, after booting the Devuan installer in live mode
sudo cryptsetup open /dev/nvme0n1p2 crypt
sudo vgchange -ay vg0
sudo mount /dev/vg0/root /mnt
sudo mount /dev/vg0/boot /mnt/boot
sudo mount /dev/vg0/home /mnt/home
sudo mount /dev/nvme0n1p1 /mnt/boot/efi
sudo mount --bind /dev /mnt/dev
sudo mount --bind /dev/pts /mnt/dev/pts
sudo mount --bind /proc /mnt/proc
sudo mount --bind /sys /mnt/sys
sudo mount --bind /run /mnt/run
sudo chroot /mnt

You’re now inside the broken system as root. Common fixes from here:

  • Bad GRUB kernel parameter: edit /etc/default/grub, update-grub.
  • Broken initramfs: update-initramfs -u -k all.
  • Bad package: apt install --reinstall <package> or dpkg --remove --force-all <package> and reinstall fresh.
  • Forgotten user password: passwd <username>.
  • Locked-out from sudo: usermod -aG sudo <username>.

After fixes, exit the chroot:

exit
sudo umount -R /mnt/dev /mnt/proc /mnt/sys /mnt/run
sudo umount /mnt/boot/efi /mnt/boot /mnt/home /mnt
sudo vgchange -an vg0
sudo cryptsetup close crypt
sudo reboot

If the LUKS volume itself refuses to open with the correct passphrase, the header is damaged. Restore the header backup from Part 2.1:

age -d ~/luks-header.bin.age > /tmp/luks-header.bin
sudo cryptsetup luksHeaderRestore /dev/nvme0n1p2 --header-backup-file /tmp/luks-header.bin
shred -u /tmp/luks-header.bin

This is destructive (it overwrites the on-disk header with the backup). Be sure you have the right backup file before running it.

Appendix A: Changing the LUKS passphrase

Every six to twelve months, or whenever you suspect the passphrase may have been observed, change it. The procedure is:

sudo cryptsetup luksChangeKey /dev/nvme0n1p2

Enter the existing passphrase, then the new one, twice. The change takes a few seconds (PBKDF2 iterations).

After the change:

  1. Take a new LUKS header backup per Part 2.1. The old backup contains the old passphrase’s key slot and will not unlock with the new one.
  2. Destroy the old encrypted header backup files. Replace them with the new one in every location they live.
  3. If you have TPM2-sealed unlock enrolled (Part 4.1), re-enrol it; the old TPM-sealed key references the old key slot.

To add a second passphrase (recovery passphrase, written on paper, kept in a safe) without removing the first:

sudo cryptsetup luksAddKey /dev/nvme0n1p2

Enter an existing passphrase to authenticate, then the new one. Two passphrases now unlock the disk; either works at boot. Useful for a high-entropy daily passphrase plus a written recovery passphrase you’d never type but could find in an emergency.

To remove a passphrase:

sudo cryptsetup luksRemoveKey /dev/nvme0n1p2

It prompts for the passphrase to remove (the one you’re getting rid of, not the one you’re keeping). Confirm the remaining passphrase still works before walking away from the keyboard.

Appendix B: Threat model and trade-offs

This setup defends against:

  • A stolen powered-off laptop. The disk is encrypted; the thief gets a brick.
  • Casual local users on a logged-in but locked machine. The screen lock plus tmpfs /tmp permissions plus AppArmor confines what a curious passerby can do.
  • Most opportunistic malware. AppArmor confines browsers and document viewers; the sysctl tightening closes the standard local-priv-esc paths; kernel lockdown denies rootkit persistence; encrypted DNS prevents the most common network manipulation.
  • Network observers on shared WiFi. MAC randomisation plus encrypted DNS plus regular HTTPS gives a normal user a hostile-network posture comparable to what a corporate VPN gives a remote employee.
  • Long-term data linkability via ISP DNS logs. Encrypted DNS to a no-log resolver moves the trust point from “definitely logged” to “claims not to log.”

It does not defend against:

  • A powered-on laptop with the screen unlocked. The data is decrypted in memory; nothing here helps once the attacker has hands on the keyboard with a logged-in session.
  • A nation-state with physical access to a powered-off machine. Cold-boot attacks on suspended laptops, evil-maid attacks on a stationary target, firmware-level implants, supply-chain interception. Kernel lockdown plus Heads coreboot plus a TPM with PCR-bound unlock raises the bar but does not close the category. If this is your threat model, Qubes-Whonix on Heads-coreboot hardware is the starting point, not Devuan.
  • A user who runs malware as themselves. AppArmor mitigates; it does not prevent. The user clicked the thing.
  • Compelled disclosure. A government with rubber hoses gets the passphrase. The narrow technical exception (VeraCrypt hidden volumes) is not part of this setup and is high-stakes in its own right.
  • Backup loss. Off-machine backups are good; they don’t help if both the original and the backup live in the same building during a fire.

Appendix C: What this doesn’t cover

Things deliberately out of scope:

  • Host integrity monitoring (file integrity, package integrity, configuration drift). Recommendation: install AIDE, debsums, and auditd from the apt repository; do not write a bespoke monitor.
  • Browser hardening. Tor Browser, LibreWolf, and arkenfox are the three meaningful tracks. Browser choice is a separate document and is part of a separate decision (privacy from websites, not from local attackers).
  • Hardware tokens (open-hardware preferred). Worth using for SSH keys, sudo authentication, GPG signing, and 2FA. The integration is straightforward (pam_u2f, gpg-agent, OpenSSH FIDO support). For a sovereignty-focused setup, the default is Nitrokey 3 (German, firmware open-source, Trussed framework in Rust, mature ecosystem). Open-all-the-way-down options are Tillitis TKey (RISC-V, fully open hardware, measured-boot-per-application model) and Precursor (open silicon, FPGA-based, pocket-device form factor; the answer when you mean “verify everything yourself”). YubiKey is widely used and reliable but its firmware is closed and unauditable: coherent only if you accept “trust Yubico’s claims” as a primitive. Covered more fully in the master security overview and in the privacy-setup companion guide.
  • Password manager. KeePassXC is the standard open answer: local-first (no cloud sync forced on you), C++ with a long track record, format compatible with the broader KeePass ecosystem (KeePass2 on Windows, KeePassDX on Android, MacPass on macOS). For cloud-sync, syncthing the KDBX database across machines or store it in your Borg backup. Don’t use anything where the vendor has the keys.
  • Air-gapped signing machine. The Knots-lens / cold-storage operator practice is to keep high-value cryptographic operations (Bitcoin signing, GPG primary-key operations, code release signing) on a separate, never-online machine. This document covers the daily-driver online workstation; the air-gapped machine is a different setup with different priorities (deterministic boot, manual update discipline, no networking hardware). The two-machine architecture is the Knots-default; this doc is one half of it.
  • Whonix-Gateway as a network filter. A lighter alternative to the full two-VM Whonix setup in Part 4.5: run only the Gateway and point a specific browser or VM at its SOCKS port. Lower friction; protects only the applications you remember to route through it. The full configuration (both VMs, optionally on a separate user account) lives in Part 4.5 of this doc. Broader Whonix context and the Qubes-Whonix path are in Whonix.
  • Dotfiles management (storing shell config, SSH config, GIT config across machines under version control with encrypted-secrets support). Key keys (SSH and GPG private keys) do NOT belong in any dotfiles repo, encrypted or not; the repo configures the use of keys, the keys themselves are separately managed.
  • fail2ban for SSH brute-force protection. This document does not install an SSH server by default; without sshd, fail2ban has nothing to protect and is unused. If you enable sshd for remote access (apt install openssh-server), install fail2ban as the standard companion (apt install fail2ban). Default configuration ships with sensible jails for sshd and a 10-minute ban after 5 failed attempts. Disable password authentication in /etc/ssh/sshd_config (set PasswordAuthentication no) and require key-based auth as a pre-condition; fail2ban then protects against brute-force on the key-auth side and against script-kiddie scanning generally.

Full Disk Encryption with LUKS2 + LVM on Devuan

/boot inside encrypted volume, single passphrase at boot

  • For: someone installing Devuan with full-disk encryption, /boot included, by hand.
  • Before you start: the non-free firmware ISO on a USB stick, a machine you can wipe, and a passphrase (see Creating passphrases).
  • Reading time: about 8 minutes; the install itself takes longer.
  • Tested on: Devuan Daedalus 5.0, amd64, NVMe disk.
  • You end with: a Devuan install on LUKS2 and LVM that asks for one passphrase at boot.

Before you begin

Download the non-free firmware ISO from:

https://www.devuan.org/get-devuan

Look for:

devuan_daedalus_5.0_amd64_non-free-firmware.iso

Use the non-free firmware ISO: it includes WiFi and other hardware firmware so everything works after install.

Part 1: Boot from the Devuan USB

Power on with the Devuan USB inserted. Select “Install” from the boot menu.

Part 2: Drop to a shell immediately

On the first installer screen, before selecting anything, press Alt+F2.

We partition the disk here before the installer touches it. If the installer creates partitions first it holds the disk open and the kernel cannot re-read the partition table: partitions will disappear from /dev later.

Create partitions

fdisk /dev/nvme0n1

Follow each prompt exactly. Commands are in code blocks. What you see on screen is in quotes.

g

“Created a new GPT disklabel”

n

“Partition number (1-128, default 1):” → Enter (default)

“First sector:” → Enter (default)

“Last sector:”

+1G

“Created a new partition 1 of type ‘Linux filesystem’ and of size 1 GiB”

If prompted: “Partition #1 contains a vfat signature. Do you want to remove the signature?”

y
t

“Partition type or alias (type L to list all):”

1

“Changed type of partition ‘Linux filesystem’ to ‘EFI System’”

n

“Partition number (1-128, default 2):” → Enter (default)

“First sector:” → Enter (default)

“Last sector:” → Enter (uses rest of disk)

If prompted: “Partition #2 contains a signature. Do you want to remove the signature?”

y
p

Confirm you see:

  • nvme0n1p1: 1 GB, EFI System
  • nvme0n1p2: remainder, Linux filesystem

The 1 MB free space at the top and 335 KB at the bottom are normal GPT gaps: ignore them.

w

“The partition table has been altered. Syncing disks.”

Format the ESP

mkfs.fat -F 32 /dev/nvme0n1p1

Warnings about codepage 850 and ANSI conversion are harmless: ignore them.

Confirm both partitions exist

ls /dev/nvme0n1*

Expected: nvme0n1 nvme0n1p1 nvme0n1p2

Part 3: Set up LUKS2

cryptsetup luksFormat --type luks2 --pbkdf pbkdf2 --cipher aes-xts-plain64 --key-size 512 --hash sha256 /dev/nvme0n1p2

“Are you sure? (Type ‘YES’ in capital letters):”

YES

Enter your LUKS passphrase twice.

This passphrase encrypts your entire disk. There is no recovery if you forget it. You will type it once at every boot.

Why --pbkdf pbkdf2? GRUB cannot process Argon2id, which is LUKS2’s default KDF. PBKDF2 allows GRUB to unlock the container at boot. The initramfs uses a keyfile for the second unlock, bypassing the KDF entirely: no security penalty. This is the unavoidable tradeoff of keeping /boot inside the encrypted volume.

Open the container

cryptsetup luksOpen /dev/nvme0n1p2 crypt

Part 4: Set up LVM

Check your RAM if you want hibernation:

free -h
SituationSwap size
No hibernation needed4G
Want hibernationMatch your RAM (e.g. 16G for 16 GB RAM)

Hibernation saves everything to disk and powers off. Suspend saves to RAM and uses minimal power. Most users want suspend, not hibernation.

pvcreate /dev/mapper/crypt
vgcreate vg0 /dev/mapper/crypt
lvcreate -L 1G -n boot vg0
lvcreate -L 4G -n swap vg0
lvcreate -L 40G -n root vg0
lvcreate -l 100%FREE -n home vg0

What each volume does:

VolumeSizePurpose
boot1GKernel and bootloader files. A few hundred MB used; 1G gives headroom for multiple kernels.
swap4GVirtual memory. Match your RAM if you want hibernation.
root40GOS, applications, libraries, package cache. Go 60–80G for heavy dev work.
homeRemainderPersonal files: documents, downloads, browser profiles, etc.

Format the volumes

mkfs.ext4 /dev/vg0/boot
mkfs.ext4 /dev/vg0/root
mkfs.ext4 /dev/vg0/home
mkswap /dev/vg0/swap

Verify

ls /dev/mapper/

Expected: control crypt vg0-boot vg0-home vg0-root vg0-swap

ls /dev/vg0/

Expected: boot home root swap

Part 5: Return to installer

Press Alt+F1.

Work through the installer screens:

Language, Location, Keyboard

Select your preferences.

Network

  • WiFi: Select the wireless interface (usually wlan0), then WPA/WPA2 PSK, then enter your password.
  • Ethernet: Select the wired interface: connects automatically.

Hostname

Use something generic like host or workstation. Avoid your real name or location: the hostname appears in logs and network traffic.

Domain name

Leave blank, press Enter.

Root account

Leave the root password empty to lock the root account. This disables direct root login. You use sudo for all privileged commands instead.

Full name

Leave blank or use a pseudonym. No functional purpose on a personal machine.

Username

Lowercase, no spaces. Avoid your real name: it appears in file paths and logs.

Login password

Used at the login screen and for sudo. At least 12 characters, mix of types. Do not reuse your LUKS passphrase.

Part 6: Partition Disks screen

Select “Manual”.

You will see:

/dev/nvme0n1 512GB
  1.0MB free space
  #1  1GB   ESP
  (remaining space — no label)
  335KB free space
  • The 1.0 MB and 335 KB entries are normal GPT gaps: ignore them.
  • The unlabelled remaining space is nvme0n1p2 (your LUKS container): do not touch it.
  • Phantom partitions under nvme0n1 may appear: stale cached view. Ignore everything under nvme0n1 except p1.

Select “Configure the Logical Volume Manager”.

“Keep current partition layout and configure LVM? Yes/No” → Yes

You will see the LVM configuration summary. Select “Continue”, then “Finish”, then “Finish” again.

Part 7: Assign mount points

Volumes appear in this order: boot, home, root, swap. For each: select the #1 entry → set “Use as” → set mount point → select “Done setting up the partition”.

vg0 LV boot (1.1 GB)

  • Use as: ext4 journaling file system
  • Mount point: /boot

vg0 LV home (461 GB)

  • Use as: ext4 journaling file system
  • Mount point: /home

vg0 LV root (42.9 GB)

  • Use as: ext4 journaling file system
  • Mount point: /

vg0 LV swap (4.3 GB)

  • Use as: swap area

nvme0n1p1: already set as EFI System Partition. Leave it alone.

nvme0n1p2: do not touch.

“Bootable flag: off”; leave it off. On GPT/EFI this flag is meaningless.

Confirm your layout:

vg0-boot   1.1GB   ext4   /boot
vg0-home   461GB   ext4   /home
vg0-root   42.9GB  ext4   /
vg0-swap   4.3GB   swap
nvme0n1p1  1GB     ESP

Select “Finish partitioning and write changes to disk”.

If prompted: “No partition table changes… Continue?” → Yes

If prompted: “Partition #2 has been written but we have been unable to inform the kernel of the change” → Ignore; normal and safe.

Part 8: Remaining installer steps

Mirror and proxy

Select your country mirror. Leave HTTP proxy blank.

Package usage survey

Select No.

Software selection

OptionRecommendation
Devuan desktop environmentSelect
XFCERecommended: lightweight, stable, low resource use
GNOMEHeavier, more modern, higher RAM use
KDE PlasmaFeature-rich, highly customisable, higher RAM use
Standard system utilitiesAlways select

Recommended for a privacy-focused desktop: XFCE + Standard system utilities

Init system

Select sysvinit: Devuan’s init system, avoids systemd.

Part 9: GRUB install

The installer will attempt to install GRUB and fail, because GRUB_ENABLE_CRYPTODISK=y is not set yet. You fix this mid-install from the installer’s shell, then retry, and it succeeds.

Your firmware may first show prompts:

“Force GRUB install to EFI removable media path?” → Yes

“Update NVRAM variables to automatically boot into Devuan?” → Yes

The installer will then show:

“Unable to install GRUB in dummy. This is a fatal error”

Select Go Back. You will be returned to the installer menu.

Fix GRUB_ENABLE_CRYPTODISK from the installer shell

From the installer menu, select “Execute a shell”.

echo 'GRUB_ENABLE_CRYPTODISK=y' >> /target/etc/default/grub

Verify it was written:

grep GRUB_ENABLE_CRYPTODISK /target/etc/default/grub

Should show: GRUB_ENABLE_CRYPTODISK=y

exit

Retry the GRUB install

Back in the installer menu, select “Install the GRUB boot loader” again.

This time it will succeed: no fatal error. The installer writes a GRUB EFI image with cryptodisk support baked in, capable of unlocking your LUKS container at boot.

Let the rest of the install finish. Do not reboot when prompted.

Part 10: Configure before first boot

This is the most critical part. Do not skip or reorder any steps.

From the installer menu, select “Execute a shell”.

The installer has already mounted everything under /target. Confirm before chrooting:

Check mounts

mount | grep /target

Expect to see: vg0-root on /target, vg0-boot on /target/boot, devtmpfs on /target/dev, proc on /target/proc, sysfs on /target/sys, and nvme0n1p1 on /target/boot/efi.

If anything is missing, mount only what is needed:

mount /dev/vg0/root /target
mount /dev/vg0/boot /target/boot
mount --bind /dev /target/dev
mount --bind /proc /target/proc
mount --bind /sys /target/sys
mkdir -p /target/boot/efi
mount /dev/nvme0n1p1 /target/boot/efi

Chroot into the installed system

chroot /target

Your prompt will change. You are now inside the installed system.

Confirm cryptsetup-initramfs is installed

dpkg -l | grep cryptsetup-initramfs

If nothing is returned, install it:

apt install cryptsetup-initramfs

The Devuan installer does not set up encryption itself: you did it manually before the installer ran. It may not have pulled in this package. Without it, the initramfs has no crypto support at all: no modules, no unlock scripts. Nothing will work.

Confirm the LUKS container is mapped

ls /dev/mapper/crypt

If it does not exist, re-open it:

cryptsetup luksOpen /dev/nvme0n1p2 crypt

The initramfs build process needs the LUKS device to be open and mapped. It inspects the live device to determine which crypto modules to pack into the image. If /dev/mapper/crypt is missing when update-initramfs runs, the resulting initramfs will be missing the modules needed to unlock the disk at boot.

Create the keyfile

mkdir -p /etc/luks
dd if=/dev/urandom of=/etc/luks/keyfile.key bs=4096 count=1
chmod 600 /etc/luks/keyfile.key
chown root:root /etc/luks/keyfile.key

Add the keyfile to the LUKS container

cryptsetup luksAddKey /dev/nvme0n1p2 /etc/luks/keyfile.key

Enter your LUKS passphrase when prompted.

Write crypttab

echo 'crypt /dev/nvme0n1p2 /etc/luks/keyfile.key luks' > /etc/crypttab

Tell initramfs to include cryptsetup and the keyfile

echo 'CRYPTSETUP=y' >> /etc/cryptsetup-initramfs/conf-hook
echo 'KEYFILE_PATTERN="/etc/luks/*.key"' >> /etc/cryptsetup-initramfs/conf-hook

CRYPTSETUP=y forces cryptsetup into the initramfs. In a chroot, auto-detection of encrypted devices can fail: this bypasses it. KEYFILE_PATTERN tells the initramfs builder which keyfiles to include. By default it ignores all keyfiles listed in crypttab. Without these lines, the initramfs either lacks crypto support entirely or has no keyfile, and the system drops to an initramfs shell at boot.

Restrict initramfs permissions

echo 'UMASK=0077' >> /etc/initramfs-tools/initramfs.conf

The initramfs image now contains private key material. This sets the umask so the resulting /boot/initrd.img-* files are readable only by root, preventing non-privileged users from extracting the keyfile.

Rebuild initramfs

update-initramfs -u -k all

Devuan installs two kernels by default: you will see output for both.

Verify the keyfile is in the initramfs

ls /boot/initrd.img-*

Note the kernel string, then run for each:

lsinitramfs /boot/initrd.img-<your-kernel-string> | grep "^cryptroot/keyfiles/"

Expected: cryptroot/keyfiles/crypt.key; the tool renames the keyfile automatically using the first field of crypttab. If nothing is returned, do not reboot: check crypttab and rebuild.

Exit chroot and return to installer

exit
exit

This returns you to the installer menu. Let the installer finish and reboot from there: it runs cleanup tasks before rebooting.

Part 11: What to expect at every boot

  1. Firmware loads GRUB from the ESP
  2. GRUB prompts: “Enter passphrase for /dev/nvme0n1p2”; type your LUKS passphrase once
  3. GRUB unlocks LUKS2, activates LVM, reads kernel and initramfs from vg0/boot
  4. Initramfs finds the keyfile (packed inside the initramfs image) and unlocks root automatically
  5. Devuan boots to login: no second passphrase prompt

Why is the keyfile secure? The keyfile is inside the initramfs, which lives on vg0/boot, which is inside the LUKS2 container. An attacker with physical access cannot reach the initramfs or keyfile without your passphrase first. The keyfile only becomes accessible after GRUB has already unlocked the container.

Part 12: Harden /tmp after first boot

Open a terminal after logging in:

sudo nano /etc/fstab

Add at the bottom:

tmpfs /tmp tmpfs defaults,noexec,nosuid,nodev,nosymfollow,size=2G 0 0

Save and exit, then verify:

sudo mount -a
mount | grep tmp

You should see tmpfs mounted on /tmp. If you get an error, check the fstab line for typos before rebooting: a bad fstab entry can drop you into emergency mode.

What each option does:

OptionEffect
noexecNothing in /tmp can be executed: blocks a common malware landing spot
nosuidPrevents privilege escalation via setuid binaries in /tmp
nodevPrevents device files in /tmp
nosymfollowPrevents symlink attacks. Linux-specific (requires kernel 5.10+, Daedalus ships 6.1)
size=2GCeiling, not reserved RAM. tmpfs only uses what it needs. Bump to 4G for heavy compiling or video work

Contents clear automatically on every reboot.

Appendix A: Changing the LUKS passphrase later

If you ever need to change your passphrase (without losing the keyfile slot):

cryptsetup luksChangeKey /dev/nvme0n1p2

To see which key slots are in use:

cryptsetup luksDump /dev/nvme0n1p2 | grep ENABLED

Appendix B: Notes on security tradeoffs

  • PBKDF2 vs Argon2id: GRUB cannot use Argon2id, so PBKDF2 is required for the LUKS header. This is a known limitation of GRUB-based encrypted /boot setups, not a flaw in this guide.
  • Keyfile in initramfs: The keyfile is encrypted inside the LUKS container and only accessible after your passphrase is entered. It does not weaken security.
  • Root account locked: No direct root login is possible. All admin actions require sudo and your login password.
  • Separate /home: Keeping /home on its own LV allows reinstalling root without wiping personal data (as long as you do not reformat /home during reinstall).

QEMU and Whonix Guide

This guide shows you how to install QEMU, the program that runs virtual machines, on Debian 13, with a small [qemu-install] script.sh`. Then it shows you how to set up Whonix and use it for your anonymous accounts.

Follow the steps in order. Most of the action happens in a terminal. Copy each command from here and paste it into the terminal with Ctrl+Shift+V.

In this guide, text in code font is something you type, or exact text you should see. Text in bold is something on the screen that you click or choose.

What you are setting up

A virtual machine is a computer made of software. It runs in a window on your real computer, with its own operating system, files and programs. Your real computer is called the host. The computer in the window is called the guest.

QEMU

QEMU is the program that runs virtual machines. KVM is the part of Linux that lets QEMU use a feature built into your processor, called hardware virtualization. With it, virtual machines run at nearly full speed. Without it, they are much slower, and some won’t start at all.

Whonix

Whonix is two virtual machines that work as a pair. The Gateway connects to the Tor network. The Workstation is where you browse and work. The Workstation has no direct way out to the internet. Everything it sends goes through the Gateway and over Tor. So programs inside the Workstation can’t see your real IP address, even if they try.

libvirt and Virtual Machine Manager

Whonix’s two virtual machines are managed by libvirt, a background service on your computer that starts and stops them and sets up their networking. You control libvirt through Virtual Machine Manager, a program with start and stop buttons for each machine and a window that shows its screen. Some steps also use virsh, a command that gives libvirt the same instructions from the terminal.

What you need

  • A 64-bit Intel or AMD computer.
  • Debian 13 or Devuan 6.
  • A processor with hardware virtualization. Most processors from the last ten years have it, but it is sometimes switched off. Step 1 checks this for you.
  • At least 10 GB of free disk space. That is Whonix’s minimum. Keeping the download, and each extra Workstation you make, needs several GB more.
  • Enough memory. Each Whonix machine is set to use 2 GB, so the pair needs 4 GB on top of what your computer already uses. Whonix suggests a computer with 8 GB of RAM.
  • An internet connection. The Whonix download is just over 4 GB.

1. Check that your computer can run virtual machines

Use the script. Download it. It is a plain text file, so you can open it in a text editor and read it before you run it.

Open a terminal and run:

cd ~/Downloads
bash qemu-install.sh check

If it says this, your computer is ready:

hardware acceleration is available

If you haven’t installed anything yet, it may say this instead. That’s fine, because step 2 takes care of it:

/dev/kvm exists but you cannot read and write it
run 'qemu-install.sh install' to join the kvm group, then start a new login session

If it starts with no vmx or svm flag, switch on hardware virtualization first. If it says /dev/kvm is missing, load the KVM module first. If it says No such file or directory, fix this first.

2. Install QEMU

cd ~/Downloads
bash qemu-install.sh install

Here is what happens, in order:

  1. It asks for your password, with a line like [sudo] password for NAME:. Type your password and press Enter. Nothing shows while you type.

    If it says you are not in the sudoers file, or that there is no sudo, set up sudo first.

  2. It prints refreshing package lists..., followed by apt’s messages as it checks for the newest packages.

    If it then says apt-get update failed, fix the package source first.

  3. It prints installing or updating qemu.... Then apt lists the packages it will add and asks Do you want to continue? [Y/n]. Type y and press Enter.

  4. apt downloads and installs the packages. This takes a few minutes.

    If it stops with apt could not finish, read apt’s error first.

When it’s done, the end of what it prints looks like this, with your user name in place of NAME:

qemu is up to date: QEMU emulator version
adding NAME to kvm...
log out and back in before kvm takes effect

The first line also shows which version of QEMU you now have. The other two lines appear only if step 1 said you cannot read and write /dev/kvm. They mean the script added you to the kvm group, which lets you use hardware virtualization without being the administrator. That only takes effect when you next log in.

If the last line starts with qemu is up to date, you’re done here. Go on to step 3.

If the last line says to log out and back in, restart the computer. Logging out and back in usually works too, but restarting always does. Then check again:

cd ~/Downloads
bash qemu-install.sh check

It should say hardware acceleration is available. If it says something else, fix this first.

If you’d rather type the commands yourself, these do the same job:

sudo apt-get update
sudo apt-get install --no-install-recommends qemu-system-x86 qemu-system-gui qemu-utils ovmf
sudo adduser "$(whoami)" kvm

The first prints lines starting with Hit: or Get:, and ends with Reading package lists... Done. The second lists the packages it will add and asks Do you want to continue? [Y/n]. Type y and press Enter. The last one says it is adding your user to the group kvm. You only need it if step 1 said you cannot read and write /dev/kvm. After it, restart the computer, then run the check above.

3. Install the programs Whonix uses

Whonix needs a few more programs on top of QEMU. First refresh the package lists:

sudo apt update

It prints lines starting with Hit: or Get:. It ends by saying that all packages are up to date, or how many can be upgraded.

Then copy the install command from Whonix’s KVM page. It is in the section Install KVM, under Debian, on the line for Debian trixie+ on Intel / AMD:

sudo apt install --no-install-recommends qemu-system-x86 qemu-system-gui qemu-system-modules-spice qemu-utils libvirt-daemon libvirt-daemon-driver-qemu libvirt-daemon-driver-storage libvirt-clients virt-manager gir1.2-spiceclientgtk-3.0 passt ovmf swtpm swtpm-tools safe-rm xz-utils

If the line on their page is different, use theirs.

apt lists what it will add and asks Do you want to continue? [Y/n]. Type y and press Enter.

This is Whonix’s rootless setup. Your virtual machines run under your own account, and no background service runs with administrator rights.

4. Download Whonix

Go to Whonix’s KVM page: https://www.whonix.org/wiki/KVM

Scroll down to Download Whonix. Under GUI, then stable LXQt, click the button that starts with Download Whonix LXQt (KVM) (stable). This is the version with a desktop.

The CLI version has no desktop, only a text screen. The testers version is for people who help test new releases, so skip it.

The file’s name ends in .Intel_AMD64.qcow2.libvirt.xz. As the page says, downloading and installing Whonix means you agree to its Terms of Service and License Agreement.

Under the download button there is a box with tabs. On the tab called OpenPGP LXQt stable, click both buttons:

  • OpenPGP Signature. It saves a small file with the same name as the big one, plus .asc on the end.
  • Download Whonix OpenPGP Key. It saves a file called derivative.asc.

If your browser shows a page of text instead of saving a file, go back. Then right-click the button and choose Save Link As.

Save all three files in your Downloads folder. Make sure there are no older Whonix downloads in that folder, because the commands below would pick them up too.

5. Check that the download is genuine

This step proves the download really came from the Whonix developers and wasn’t changed on the way to you. Whonix calls it optional, but it is worth the few minutes it takes.

It uses a program called gpg, which is usually already installed. If a command below says gpg: command not found, install it with sudo apt install gnupg and try again.

First, look at the key before you trust it:

cd ~/Downloads
gpg --keyid-format long --import --import-options show-only --with-fingerprint derivative.asc

This only shows the key. It doesn’t save it yet. Find the line that starts with Key fingerprint. It must read exactly:

Key fingerprint = 916B 8D99 C38E AF5E 8ADC  7A2A 8D66 066A 2EEA CCDA

Compare it group by group. If even one character is different, or there is no Key fingerprint line at all, stop and fix this first.

The first time you ever use gpg, it may also print lines saying it created a directory and a keybox. That’s normal.

When the fingerprint matches, save the key:

gpg --import derivative.asc

You should see:

gpg: key 8D66066A2EEACCDA: public key "Patrick Schleizer <adrelanos@kicksecure.com>" imported
gpg: Total number processed: 1
gpg:               imported: 1

Patrick Schleizer is Whonix’s lead developer. If you have saved this key before, it says not changed instead, which is fine too.

Now check the download itself. This can take a minute:

gpg --verify-options show-notations --verify Whonix-*.libvirt.xz.asc Whonix-*.libvirt.xz

Look for these three things in what it prints:

  • A line starting gpg: Signature notation: file@name=, followed by the name of your download.
  • A line saying Good signature from "Patrick Schleizer. This is the one that matters most.
  • A line saying Primary key fingerprint: 916B 8D99 C38E AF5E 8ADC 7A2A 8D66 066A 2EEA CCDA.

You will also see this warning:

gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.

The warning is normal. gpg is saying that you haven’t told it you trust this key. It says nothing about the file. Comparing the fingerprint yourself, as you did above, is how you check the key.

If it says BAD signature, don’t go on, and fix this first. If it says No public key, save the key first.

6. Unpack Whonix and add it to your computer

Unpack the download

cd ~/Downloads
tar -xSvf Whonix*.libvirt.xz

Use tar exactly like this, and don’t use unxz. Whonix asks for this, and the S in -xSvf keeps the unpacked disk files small on your drive.

This takes a while. It prints each file name as it unpacks it. Among them are two files ending in .xml, two ending in .qcow2, and one called WHONIX_BINARY_LICENSE_AGREEMENT. Wait until the prompt comes back. If tar says Not found in archive, fix this first.

Whonix’s page then has you make an empty file to record that you accept the licence. Nothing uses that file, so skip it.

Adjust the libvirt settings

Two things libvirt does by default get in Whonix’s way.

First, libvirt tries to raise a limit that only the administrator may raise. When it can’t, the machines refuse to start.

Second, if you log out or shut down your computer while Whonix is running, libvirt saves the machines’ memory to your disk, and brings them back the next time it starts. Whonix strongly discourages saving, because it upsets Whonix’s clock, and the saved memory can hold what you were doing.

Open the settings file in the nano text editor:

mkdir -p ~/.config/libvirt
nano ~/.config/libvirt/qemu.conf

The first command prints nothing, and the second opens nano. Paste these five lines into it:

max_core = 0
max_processes = 0
max_files = 0
auto_shutdown_try_save = "none"
auto_shutdown_restore = 0

To save and close, press Ctrl+X, then Y, then Enter.

The first three lines come from Whonix’s page, and stop the start-up error. The last two are not on Whonix’s page. With them, libvirt asks the machines to shut down instead of saving them, switches them off if they haven’t after 30 seconds, and doesn’t start them again by itself.

Do this before the next part. libvirt reads this file only when it starts, and the next part is what starts it.

Add the two machines

cd ~/Downloads
virsh -c qemu:///session define Whonix-Gateway*.xml
virsh -c qemu:///session define Whonix-Workstation*.xml

Each command prints one line, starting like this and ending with the name of the .xml file:

Domain 'Whonix-Gateway' defined from
Domain 'Whonix-Workstation' defined from

The first command may take a few seconds, because libvirt starts a helper for your account the first time you use it. In these commands, qemu:///session means “the virtual machines that belong to my account”. If a command says unexpected data, fix this first. If it says does not support virt type 'kvm', fix this first.

Move the disk files into place

cd ~/Downloads
mkdir -p ~/.local/share/images
mv Whonix-Gateway*.qcow2 ~/.local/share/images/Whonix-Gateway.qcow2
mv Whonix-Workstation*.qcow2 ~/.local/share/images/Whonix-Workstation.qcow2

These print nothing. Each disk file is labelled as 100 GB, but only takes up the space it really holds. If you ever copy them, use cp --sparse=always, as Whonix says, so the copy stays small too.

Tell libvirt where the disk files are

Whonix’s machine settings look for their disk files in a storage pool called default. A storage pool is libvirt’s name for a folder of disk files. Without this part, the Gateway won’t start.

Do this before you open Virtual Machine Manager for the first time. Otherwise Virtual Machine Manager makes its own pool called default, in a different folder.

virsh -c qemu:///session pool-define-as default dir --target ~/.local/share/images
virsh -c qemu:///session pool-start default
virsh -c qemu:///session pool-autostart default

These create the pool for that folder, switch it on, and set it to switch on by itself from now on. They print:

Pool default defined
Pool default started
Pool default marked as autostarted

If the first one says the pool default already exists, fix this first.

Now check that libvirt sees both disk files:

virsh -c qemu:///session vol-list default

It lists both, with your user name in place of NAME:

 Name                       Path
---------------------------------------------------------------------------------
 Whonix-Gateway.qcow2       /home/NAME/.local/share/images/Whonix-Gateway.qcow2
 Whonix-Workstation.qcow2   /home/NAME/.local/share/images/Whonix-Workstation.qcow2

If one is missing, check that both files are in the folder with ls -l ~/.local/share/images. Move any missing file there as shown above, then run virsh -c qemu:///session pool-refresh default and check again.

Keep the download

Whonix suggests deleting the download and the leftover files now, or moving them somewhere else, so an old copy can’t get mixed up with a newer one later. Move them into a folder of their own. Then you can set up Whonix on another computer without downloading it again.

cd ~/Downloads
mkdir -p ~/whonix-download
mv Whonix* WHONIX* derivative.asc ~/whonix-download/

These print nothing. Your virtual machines don’t need these files. Their disk files are now in ~/.local/share/images.

To set Whonix up on another computer, copy this folder, not the disk files. On most USB sticks, each disk file would take up its full 100 GB, or wouldn’t fit at all. Use a USB stick formatted as exFAT or ext4. FAT32, the format many sticks come with, can’t hold a file as big as the download.

If you set Whonix up from these files again later, it will be out of date. Update it straight away, as in step 8.

7. Start Whonix

Open Virtual Machine Manager

Open Virtual Machine Manager. It is in your applications menu, usually under System. You can also start it by typing virt-manager in a terminal.

The first time, its window shows a line called QEMU/KVM, which may say Not Connected. An error may also pop up, saying it can’t connect. That line is for virtual machines set up for the whole computer, and this guide doesn’t use it. Close the error. It doesn’t come back.

You can leave the QEMU/KVM line there. To remove it, first add your own connection below. Then right-click the QEMU/KVM line, choose Delete, and click Yes. That only removes the line from the list.

Connect to your own virtual machines

  1. Click File, then Add Connection.
  2. Set Hypervisor to QEMU/KVM user session. A note appears, saying the user session is not the default. That’s expected.
  3. Leave Autoconnect ticked. It is ticked already.
  4. Click Connect.

A new line appears, called QEMU/KVM User session. Under it are Whonix-Gateway and Whonix-Workstation, both marked Shutoff. Because Autoconnect is ticked, Virtual Machine Manager opens this connection by itself from now on. If the two machines aren’t there, fix this first.

Using a machine’s window

Each machine has its own window. To open it, double-click the machine in Virtual Machine Manager. You see and use the machine there. Read this before you start one, because the window works differently from other windows in a few ways:

  • Closing the window doesn’t stop the machine. It keeps running. To open the window again, double-click the machine in Virtual Machine Manager. To stop the machine, shut it down, as Start and stop shows.
  • The Run button in Virtual Machine Manager’s main window also starts a machine, but doesn’t open its window. Double-click the machine to see it.
  • The machine’s screen shrinks or grows to fit its window. To make it bigger or smaller, resize the window, or use the window’s maximize button. In the window’s View menu, under Scale Display, keep Always chosen and Auto resize VM with window unticked. Whonix’s KVM page suggests this instead of changing the machine’s screen resolution.
  • Inside the Workstation, don’t change the screen resolution or the text size, and don’t maximize or resize Tor Browser’s window. Whonix’s Screen page asks you to keep all three at their defaults.
  • Full screen fills your whole screen with the machine. To turn it on, click the button at the right end of the window’s toolbar, or click View, then Fullscreen. To leave full screen, move the pointer to the very top of the screen, in the middle. A small bar appears there. Click Leave Fullscreen on that bar.
  • The machine may keep your mouse pointer after you click inside it. The window’s title then says which keys release it, usually the left Ctrl and left Alt keys pressed together. In full screen there is no title, but the same keys work.
  • While the pointer is over the machine’s screen, your computer’s own shortcuts, such as the ones that switch workspaces, go to the machine instead. To go to another workspace on your computer, press and release the left Ctrl and left Alt keys together, then press the shortcut before you move the mouse. In XFCE the shortcut is Ctrl+Alt+Left or Ctrl+Alt+Right, or Ctrl+F1, Ctrl+F2 and so on for a numbered workspace. Moving the mouse over the machine gives it the keys again, so if nothing happens, move the mouse a little and try again. When the machine is in a window, not full screen, you can instead move the pointer off the machine’s screen, onto the window’s title bar for example, and press the shortcut.
  • Copy and paste between your computer and the machine doesn’t work, and neither does dragging files into it. Whonix switches both off on purpose. To bring in files, see Copy files into the Workstation.

Start the Gateway

  1. Double-click Whonix-Gateway. Its own window opens, saying Guest is not running.
  2. Click Run in that window’s toolbar. It is shaped like a triangle, and its tooltip says Power on the virtual machine.
  3. Some start-up text and a boot menu appear for a few seconds. The boot menu then starts the normal session by itself. Wait for the Gateway’s desktop.

If an error appears instead, find its message here:

Connect to Tor

For safety, Whonix doesn’t connect to Tor by itself the first time it starts. Instead, a window called Anon Connection Wizard opens on the Gateway’s desktop. If it doesn’t appear, open it yourself.

It asks which option best describes your situation:

  • Connect is already chosen. Choose it if Tor isn’t blocked where you are. It works in most places.
  • Configure is for places where Tor is blocked, or where using it is dangerous or looked on with suspicion. It lets you use bridges, which are entry points to the Tor network that aren’t on its public list. Tick the box that says you need bridges, and choose obfs4. Whonix suggests trying obfs4 first, and meek only if obfs4 doesn’t work. Leave the proxy box unticked, unless your network makes you use a proxy. Bridges help you get past a block, but Whonix says hiding your Tor use from your internet provider isn’t possible in practice.

Click Next until it starts connecting. When it says Tor bootstrapping done, click Finish. If it can’t connect, fix this first.

The Gateway keeps your choice, so later starts connect the same way. To change it, run Anon Connection Wizard again from the Gateway’s start menu.

Start the Workstation

Leave the Gateway running. The Workstation reaches the internet only through it.

Go back to Virtual Machine Manager’s main window. Double-click Whonix-Workstation, then click Run, as you did for the Gateway. Both machines log in by themselves, to an account called user.

Check the connection

In the Workstation, open a terminal from the start menu and run:

systemcheck

It can take a few minutes. Among the lines it prints, you should see:

[INFO] [systemcheck] Connected to Tor.

If it reports a problem instead, it names what needs attention. The most common one is a Gateway that isn’t connected yet, so start with this fix.

If your keyboard types the wrong characters inside Whonix, set the keyboard layout as Whonix’s Post Install Advice shows.

Whonix is now working. Update both machines next, in step 8.

8. Keep Whonix up to date

Whonix keeps everyday use and system changes apart. Your everyday session can’t install anything. That job belongs to a separate session called sysmaint, short for system maintenance. So if harmful software ever gets into your browser, it can’t change the system.

Update the Gateway first, then the Workstation. The Workstation’s updates come in through the Gateway, so keep the Gateway running while the Workstation updates.

For each machine:

  1. Restart it. Use the machine’s own start menu, or click the small arrow next to Shut Down in its window, then Reboot.
  2. When the boot menu appears, click inside the window. Use the arrow keys to choose the entry with SYSMAINT in its name, and press Enter. You have a few seconds before it starts the normal session by itself. If it does, restart and try again.
  3. The machine starts a plain desktop with the System Maintenance Panel open. Click Check for Updates. A terminal opens and checks for the newest packages.
  4. When it has finished, click Install Updates. If it asks Do you want to continue? [Y/n], type y and press Enter.
  5. When that has finished, click Reboot. The machine starts its normal session by itself.

If you prefer typing, click Open Terminal in the System Maintenance Panel instead, and run:

sudo apt update && sudo apt full-upgrade

Whonix asks you to check for updates regularly and install them promptly.

Tor Browser, inside the Workstation, updates itself. When it offers an update, let it restart.

If sudo inside Whonix ever says permission denied, you are in the everyday session. Restart into sysmaint as above.

9. Tighten security

Do these once

Remove the sound device. Whonix’s hardening checklist recommends removing each machine’s virtual sound card. Whonix’s settings already leave the microphone out, but the speakers, which can be used to profile you, are still there. Do this for the Gateway, which never needs sound, and for the Workstation, unless you want sound from it:

  1. Shut the machine down.
  2. In Virtual Machine Manager, double-click the machine to open its window. Click View, then Details. A list of the machine’s parts appears on the left.
  3. In that list, click Sound ich9.
  4. Click Remove, then Yes.

To see the machine’s screen again, click View, then Console.

Set Tor Browser to Safest. In the Workstation, start Tor Browser from the start menu. Open its menu, the three lines at the top right, and choose Settings, then Privacy & Security. Under Security Level, click Change if there is such a button. Choose Safest, then save and restart Tor Browser when it asks. At Safest, JavaScript is off on every site, and websites only get the features that simple pages need, so some sites won’t work. Whonix’s advice is to allow JavaScript only sparingly, for sites you trust. Don’t save per-site exceptions that last from one session to the next, because they make you easier to recognise.

Leave these as they are

Whonix switches these off in its machine settings, or advises against them, because they weaken the separation between your computer and Whonix. Leave them off:

  • Copy and paste between your computer and Whonix.
  • Dragging files between your computer and Whonix.
  • 3D graphics.
  • Sharing memory between machines, and letting a machine’s memory grow and shrink while it runs.
  • Passing devices from your computer into Whonix, such as USB sticks, phones or cameras. In a machine’s window, don’t use Virtual Machine, then Redirect USB device.
  • Shared folders, unless you need one to bring files in. If you do, use the read-only kind that Copy files into the Workstation shows.

Each machine’s settings also carry Whonix’s own note: don’t change anything unless you understand the consequences.

Inside Whonix, leave these alone too:

  • The clock and the time zone. Whonix sets them to UTC, the world’s standard time, so your own time zone can’t give you away.
  • The settings of programs that go online, such as Tor Browser. Whonix’s advice is to change them only when you know what the change does.
  • Tor Browser’s add-ons. The Tor Project strongly discourages adding any, because they can make your browser stand out or leak data.

Habits that keep you anonymous

Whonix hides your IP address, but what you do can still identify you. Here is Whonix’s advice, in short:

  • Keep your anonymous life and your normal life apart. Use Tor Browser in the Workstation for one, and your normal browser on your computer for the other, never both for the same thing.
  • Don’t log in to accounts tied to your real name. Social networks usually know who you are, even under a made-up name.
  • Use one identity at a time. Give each identity its own Workstation, as One Workstation per task shows, and shut one down before you start another.
  • Be wary of files and links people send you, even from people you know. Their account could be in someone else’s hands.
  • Clean files before you share them. Photos and documents carry hidden details that can identify you. In the Workstation, run mat2 followed by the file’s name, such as mat2 photo.jpg. It makes a cleaned copy with .cleaned in its name, such as photo.cleaned.jpg, and leaves the original as it was. If mat2 reports a problem with bubblewrap, run mat2 --no-sandbox photo.jpg instead, as Whonix’s page shows.
  • Don’t add a VPN to Whonix, and don’t run Tor again inside the Workstation. A VPN is a service that sends your traffic through another company’s server. Whonix’s checklist strongly recommends against linking a VPN with Tor, and calls Tor over Tor “undefined and potentially unsafe”.
  • Shut both machines down when you’re done, and before you log out, restart or shut down your computer. Don’t use Pause or Save in Virtual Machine Manager, and don’t let your computer go to sleep while Whonix runs, for example by closing a laptop’s lid. Whonix strongly discourages pause, suspend, save and hibernate, because they upset its clock. If it happened anyway, restart both machines.
  • Keep your computer’s clock right, to within 30 minutes. If it is more than an hour slow, or more than three hours fast, Tor can’t connect.

Whonix’s full list is on its page Tips on Remaining Anonymous.

On your own computer

  • Keep it up to date, as Updating later shows. In this setup the virtual machines run as your own account, so keeping QEMU patched protects your own files too.
  • Encrypt its disk. Everything Whonix saves lives in its disk files on your computer, so it is only as private as your computer’s disk. Kicksecure, which Whonix is built on, recommends full disk encryption on the host rather than encrypting the machines.
  • Whonix’s System Hardening Checklist has more, including changes on your own computer. Examples are hiding your network card’s address on networks away from home, and switching off TCP timestamps, which can reveal how long your computer has been running. Work through it once you’re comfortable.

Everyday use

Start and stop

To start, open Virtual Machine Manager and start the Gateway. Wait for its desktop. It connects to Tor by itself now. Then start the Workstation.

When you’re done, shut down the Workstation first, then the Gateway. Use the start menu inside each machine, or click Shut Down in its window, and click Yes when it asks. Force Off, in the menu next to Shut Down, is only for a machine that has stopped responding. It is like pulling the plug.

If Virtual Machine Manager ever shows a machine as Saved, fix this before you start it.

Log out and back in

To log out of a machine, open its start menu, choose Leave, then Logout, and click Yes.

A black screen then shows Login and username:. Type user and press Enter. The account has no password, as Whonix’s Default Passwords page says. If it asks for one, press Enter without typing anything.

In a sysmaint session, type sysmaint instead. Each session only lets in its own account.

The same screen sometimes appears by itself when a machine starts. Log in the same way.

What Whonix keeps

Whonix saves your work like a normal computer. Files, bookmarks, settings and updates all stay until you delete them. This is Whonix’s persistent mode, and it is what the boot menu starts by itself.

Keep your files in folders you make for each project, inside your home folder, rather than loose in it. Tor Browser saves downloads in the Downloads folder. Move them from there into your project folders.

Dark theme

The script whonix-dark-super.sh gives the Workstation a dark desktop. It darkens the panel, the menus, the window borders and the desktop’s own programs, such as the file manager. It also makes the Super key, the one with the Windows logo on most keyboards, open a box where you type a program’s name to start it.

Run it in Whonix-Workstation before you make copies, as the next part shows, and every copy starts out dark.

  1. In the Workstation, open Tor Browser, download the script from the same place you got this guide, and save it in the Downloads folder.

  2. Open a terminal from the start menu and run:

    bash ~/Downloads/whonix-dark-super.sh
    

    It prints Done. Log out and back in. Run it in the normal session, not sysmaint, and without sudo, because it changes your own settings.

  3. Log out and back in, as Log out and back in shows, or restart the Workstation.

One Workstation per task

Whonix recommends a separate Workstation for each identity, or each group of related activities. If one of them is ever compromised, the others stay private.

The simplest way to do this is to keep Whonix-Workstation clean, and only update it. For each task, make a copy of it, and use the copy.

To make a copy:

  1. Update Whonix-Workstation, as in step 8, then shut it down.
  2. In Virtual Machine Manager, right-click Whonix-Workstation and choose Clone.
  3. In Name, type a name for the task, such as Whonix-Workstation-Research, and click Clone. Copying takes a few minutes. The copy gets its own disk file next to the others, and its own network card address.

Whonix requires each extra Workstation to also have its own address inside Whonix’s network. Set it once, in the copy:

  1. Start the copy, and choose the entry with SYSMAINT in its name in its boot menu, as in step 8.

  2. In the System Maintenance Panel, click Open Terminal, and run:

    sudoedit /etc/network/interfaces.d/30_non-qubes-whonix
    
  3. The file opens in a text editor, usually nano. Find the line address 10.152.152.11 and change the 11 at the end to 12. Then find the line address fd19:c33d:88bc::11 and change its 11 to 12 as well.

  4. Save and close. In nano, that’s Ctrl+X, then Y, then Enter.

  5. Check the result:

    cat /etc/network/interfaces.d/30_non-qubes-whonix | grep --invert-match "#"
    

    Both address lines should now end in 12.

  6. Click Reboot in the System Maintenance Panel. The copy starts its normal session.

For your next copy, use 13 instead of 12, then 14, and so on.

Use each copy for its task only. Keep the clean Whonix-Workstation for updating and making copies.

Run one Workstation at a time. In this setup, every Workstation uses the same link to the Gateway, so two running at once would get in each other’s way.

Each copy needs its own updates. Update it in sysmaint, like any other machine, as in step 8.

Start fresh from time to time. Whonix suggests deleting and recreating the machines you use for sensitive work every so often. To delete a copy, right-click it and choose Delete. Make sure Delete associated storage files is ticked, then click Delete. When it asks whether to delete the storage, check that the file it lists is the copy’s own disk file, then click Yes. Then make a new copy from your clean Whonix-Workstation.

Live mode, for throwaway sessions

Live mode runs a machine without saving your changes to its disk file. When you shut it down, what you did in that session is gone from the machine. To use it on the Workstation, choose the live mode entry in its boot menu.

Three cautions from Whonix and Kicksecure:

  • Live mode can make your Tor use look different from other Tor users, which could weaken your anonymity somewhat. Tor runs on the Gateway, so keep the Gateway in its normal mode.
  • Your own computer can still keep traces of what happened inside the machine, for example in swap, the part of your disk that Linux uses as extra memory. Live mode alone doesn’t make your computer forget.
  • Harmful software inside the machine could still write to its disk file. Kicksecure strongly recommends also making the disk read-only for live mode, and its Live Mode page explains this.

Copy files into the Workstation

Most of the time you won’t need to. Download what you need inside the Workstation, and keep what belongs to an identity inside that identity’s Workstation.

To bring in files from your own computer, give the Workstation a shared folder that it can read but not change. Add the folder to the Workstation that needs the files, such as a copy you made for a task. Don’t add it to the clean Whonix-Workstation, because every copy you make from it would then share the same folder.

  1. On your computer, make a new folder for that Workstation only, such as whonix-shared-research for Whonix-Workstation-Research:

    mkdir -p ~/whonix-shared-research
    

    This prints nothing.

  2. In Virtual Machine Manager, shut down the Workstation that should get the files, such as Whonix-Workstation-Research. Then double-click it. Its window opens.

  3. In that window, click View, then Details. A list of the machine’s parts appears on the left.

  4. Below that list, click Add Hardware. A new window opens.

  5. In the new window’s list, click Filesystem, then fill in the form:

    • Driver: choose virtio-9p. If a note about shared memory appears, ignore it. It goes away when you choose virtio-9p.
    • Source path: type the full path to your folder, such as /home/NAME/whonix-shared-research, with your user name in place of NAME.
    • Target path: type shared. Always type shared, whatever your folder is called, because that is the name Whonix looks for.
    • Tick Export filesystem as readonly mount. This lets the Workstation read the folder but not change it.
  6. Click Finish. A line called Filesystem shared appears in the list.

  7. Click View, then Console, to see the machine’s screen again. Then click Run to start the Workstation.

To bring a file in, copy it into that folder on your computer. In the Workstation, it appears in the folder /mnt/shared. Open it from there, or copy it into your home folder. The Workstation can read everything in the folder, so put in only what it should have.

If /mnt/shared shows only a file called README.md, the folder isn’t shared. Check the steps above. The folder isn’t shared in live mode either.

If a file in /mnt/shared won’t open, make it readable on your computer with chmod, such as chmod a+r ~/whonix-shared-research/photo.jpg, then try again.

Files from your computer can carry details about you, such as your name inside a document. Clean them with mat2, as in Habits that keep you anonymous, before you share them from the Workstation.

To move files out of the Workstation as well, Whonix’s Shared Folder steps show how to let it write to the folder. Files you then delete in the folder from inside Whonix go to a hidden folder called .Trash-1000 inside it. Delete that folder on your computer too, so the files can’t carry over to another session.

Updating later

QEMU

Run the install step again whenever you want the newest QEMU:

cd ~/Downloads
bash qemu-install.sh install

It refreshes the package lists and asks apt for the same packages again. apt only downloads what is new or missing. If everything is current, it says each package is already the newest version, and asks nothing. If there is an update, it lists the packages it will upgrade and asks first. Either way, the script ends with qemu is up to date: and the version you now have.

Newest means the newest version your Debian or Devuan release offers.

Everything else on your computer

Your normal system updates keep QEMU, the programs from step 3 and everything else current:

sudo apt update && sudo apt upgrade

apt lists what it will upgrade and asks first.

Whonix

Whonix updates from the inside, as in step 8. That includes every Workstation copy you made.

This script

To see which copy of the script you have, run:

cd ~/Downloads
bash qemu-install.sh version

It prints the script’s name and version number, such as qemu-install, v6. If the place you got it from has a newer copy, use that one. That matters most after you move to a new Debian or Devuan release, in case package names change.

Remove Whonix

If you made Workstation copies, delete them first in Virtual Machine Manager, as One Workstation per task shows.

Then switch off both machines and take them out of Virtual Machine Manager:

virsh -c qemu:///session destroy Whonix-Gateway
virsh -c qemu:///session destroy Whonix-Workstation
virsh -c qemu:///session undefine --managed-save Whonix-Gateway
virsh -c qemu:///session undefine --managed-save Whonix-Workstation

destroy sounds alarming, but it only switches a machine off straight away, like pulling the plug. If a machine is already off, you get an error saying the domain is not running. That’s fine. undefine takes the machine out of the list, and prints a line saying the domain has been undefined. The --managed-save part also deletes any saved state libvirt kept for it.

Then delete the disk files. This deletes everything inside Whonix, so copy out anything you want to keep first:

safe-rm ~/.local/share/images/Whonix-*.qcow2

This prints nothing.

If you no longer need the download you kept in step 6, delete it too:

safe-rm -r ~/whonix-download

The storage pool from step 6 can stay. To remove it as well, run these two commands, which leave files alone:

virsh -c qemu:///session pool-destroy default
virsh -c qemu:///session pool-undefine default

If you also want to remove the programs, run:

sudo apt remove qemu-system-x86 qemu-system-gui qemu-system-modules-spice qemu-utils libvirt-daemon libvirt-daemon-driver-qemu libvirt-daemon-driver-storage libvirt-clients virt-manager gir1.2-spiceclientgtk-3.0 passt ovmf swtpm swtpm-tools safe-rm
sudo apt autoremove

If you use QEMU for other virtual machines, leave out qemu-system-x86, qemu-system-gui, qemu-utils and ovmf. xz-utils is left off the list on purpose, because other programs often need it. Both commands show what they will remove before they ask. Read the list before you answer yes.

You can also delete the settings file from step 6, ~/.config/libvirt/qemu.conf, and any shared folders you made.

Fixes

Each fix ends with a link back to where you were. For a message that isn’t here, see the troubleshooting section of Whonix’s KVM page: https://www.whonix.org/wiki/KVM

Set up sudo

sudo lets your account run a command as the administrator, after asking for your password. Some installs don’t set it up. For example, if you gave the administrator, called root, its own password while installing Debian, your account doesn’t get sudo.

First, find out your user name:

whoami

It prints one word. That is your user name.

Next, become the administrator. Type the root password when it asks:

su -

The dash after su matters. Without it, the adduser command below may not be found. When it works, the prompt changes and ends with a # sign.

Now run these two commands, with your user name in place of NAME:

apt install sudo
adduser NAME sudo

The first one either installs sudo or says that sudo is already the newest version. The second one says it is adding your user to the group sudo.

Restart the computer so the change takes effect:

reboot

Then go back to step 2.

Switch on hardware virtualization

The check said:

no vmx or svm flag in /proc/cpuinfo
hardware virtualization is disabled in firmware, or absent from this cpu
enter firmware setup at boot; the key varies by vendor, commonly del, f2, f10 or f1
the setting is usually under security or advanced, named: intel virtualization technology, or vt-x

The last line names the setting for your processor. On AMD processors it says svm mode, or amd-v. Turn that setting on in your computer’s firmware setup, often called the BIOS:

  1. Restart the computer.
  2. As soon as it starts, press the setup key. It is often Del, F2, F10 or F1, and the start screen usually shows which.
  3. Look under Security, Advanced or CPU Configuration for the setting the script named.
  4. Turn it on.
  5. Save and exit. The key for that is often F10.

Don’t confuse VT-x with VT-d. VT-d is a different setting, and it isn’t the one you need.

If you’d rather check by hand, this prints a number:

grep -Ec '(vmx|svm)' /proc/cpuinfo

Any number above 0 means the feature is on. A 0 means it is switched off or missing.

Then go back to step 1.

Load the KVM module

The check said your processor has the feature, but /dev/kvm is missing. That means Linux hasn’t switched the feature on. Run the two commands the check printed. The first starts with sudo modprobe and the second with sudo dmesg. If the second one mentions disabled by bios, switch the setting on in your firmware.

Then go back to step 1.

Refreshing the package lists failed

The lines above the message name the package source that failed. If the address in them contains debian.org or devuan.org, the problem is your connection or their servers, so try again later. If it is a source that was added for some other program, fix it by following that program’s instructions, or switch it off. Those sources are files ending in .list or .sources in /etc/apt/sources.list.d/. To switch one off, move its file out of that folder with sudo mv, for example into your home folder. Moving it back switches it on again.

Then go back to step 2.

apt could not finish

apt says why in its own messages, above the script’s. Look for the line starting with E::

  • Unable to locate package means this copy of the script is too old for your system. Get a newer copy from the place you got this guide.
  • Could not get lock means another program is installing or updating software, often your desktop’s update tool. Wait for it to finish, then run the script again.
  • Failed to fetch means a download failed. Check your internet connection, then run the script again.
  • dpkg was interrupted means an earlier install was cut short. Run the command apt names, sudo dpkg --configure -a, then run the script again.

Then go back to step 2.

Hardware acceleration is still not available

Run the check again after the restart, and read what it says:

  • you are in the kvm group, but it takes effect only after you log out and back in means the new login hasn’t happened yet. Restart the computer, then check again.
  • you are in the kvm group, but /dev/kvm still does not let you read and write it means the device has unusual permissions. Run ls -l /dev/kvm. Normally the line starts with crw-rw---- and shows the group kvm. Device permissions are set again each time the computer starts, so restart it and check again.
  • If it says anything else, follow the fix linked from step 1.

When the check says hardware acceleration is available, go on to step 3.

No such file or directory

The terminal isn’t in the folder with your files, or the file name is different. Run cd ~/Downloads, then ls, to see the exact names. If the script’s name is different, use that name in the command.

Then go back to the step you were on.

The key fingerprint does not match

Don’t save the key.

If gpg printed no fingerprint at all and said no valid OpenPGP data found, your browser saved a web page instead of the key. Download it again with Save Link As, as step 4 describes.

If it printed a different fingerprint, delete derivative.asc and download it again from Whonix’s KVM page, with the Download Whonix OpenPGP Key button. If the fingerprint is still different, the key you are getting isn’t Whonix’s. Don’t go on, and ask for help on Whonix’s forum: https://forums.whonix.org

Then go back to step 5.

gpg cannot find the public key

gpg says Can't check signature: No public key when you haven’t saved the Whonix key yet. Do the gpg --import derivative.asc part of step 5.

Then go back to step 5.

gpg reports a BAD signature

First, make sure there is only one Whonix download in the folder:

cd ~/Downloads
ls Whonix*

It should list only your download and its .asc file. Extra files make the check fail, so move any others out of the folder, into ~/whonix-download for example. If there was only one download, delete it and its .asc file, and download both again. Don’t go on until gpg says Good signature.

Then go back to step 5.

More than one download in the folder

When tar says Not found in archive, or virsh says unexpected data, more than one file matched the name pattern. Run ls Whonix* in your Downloads folder to see them. Move the older ones out of the folder, into ~/whonix-download for example, and run the command again.

Then go back to step 6.

A storage pool called default already exists

This happens when Virtual Machine Manager’s user session was opened before step 6, or when you made a pool earlier. Check which folder the pool uses:

virsh -c qemu:///session pool-dumpxml default | grep '<path>'

If it prints <path>/home/NAME/.local/share/images</path>, with your user name, the pool is already right. Make sure it is on and has seen the disk files:

virsh -c qemu:///session pool-start default
virsh -c qemu:///session pool-autostart default
virsh -c qemu:///session pool-refresh default

If pool-start says the pool is already active, that’s fine. The other two print Pool default marked as autostarted and Pool default refreshed. Then go back to where you came from: the vol-list check in step 6, or Start the Gateway in step 7.

If it prints any other folder, take the old pool away:

virsh -c qemu:///session pool-destroy default
virsh -c qemu:///session pool-undefine default

They print Pool default destroyed and Pool default has been undefined. If pool-destroy says the pool is not active, that’s fine too. Neither command deletes any files. Then run the three pool commands and the vol-list check in step 6. If you came here from step 7, go back to Start the Gateway afterwards.

The machines are not in the list

If you are looking under the QEMU/KVM line, add the user session connection as step 7 shows. If the QEMU/KVM User session line is there but empty, check what libvirt has:

virsh -c qemu:///session list --all

It should list both machines:

 Id   Name                 State
-------------------------------------
 -    Whonix-Gateway       shut off
 -    Whonix-Workstation   shut off

If they are missing, add them again. If you have already moved the download, as step 6 shows, the files are in ~/whonix-download:

cd ~/whonix-download
virsh -c qemu:///session define Whonix-Gateway*.xml
virsh -c qemu:///session define Whonix-Workstation*.xml

If you haven’t moved it yet, run the two define commands in ~/Downloads instead. Each prints a line saying the domain was defined.

Then go back to step 7.

Storage volume not found

The Gateway’s disk file isn’t where libvirt looks for it. Check your pools:

virsh -c qemu:///session pool-list --all

If no pool called default is listed, do Tell libvirt where the disk files are from step 6. If it is listed, follow A storage pool called default already exists.

Then go back to step 7 and click Run again.

Storage pool is not active

Switch the pool on, and make it switch on by itself from now on:

virsh -c qemu:///session pool-start default
virsh -c qemu:///session pool-autostart default

They print Pool default started and Pool default marked as autostarted.

Then go back to step 7 and click Run again.

Cannot limit core file size or open files

The settings file from step 6 is missing, has a typo, or was made after libvirt’s helper started. Open it again:

nano ~/.config/libvirt/qemu.conf

Compare it with the lines in step 6, fix any difference, and save. Then shut down any machine that is running, close Virtual Machine Manager, wait two minutes, and open it again. libvirt’s helper reads this file only when it starts, and it stops by itself two minutes after nothing is using it. Restarting the computer works too.

Then go back to step 7 and click Run again.

KVM is not available

libvirt says this in one of two ways. In step 6 it says the emulator does not support virt type 'kvm'. In step 7 it says Domain requires KVM, but it is not available. Either way, libvirt can’t use your processor’s hardware virtualization. Run the check from step 1 and do what it says:

cd ~/Downloads
bash qemu-install.sh check

When the check says hardware acceleration is available, go back to where you were. If libvirt still says the same, restart the computer and try again.

From step 6, run the two define commands in Add the two machines again. From step 7, click Run again in Start the Gateway.

Another virtualization program is running

A message that mentions KVM_CREATE_VM means another program, such as VirtualBox, is using your processor’s hardware virtualization. Close that program’s virtual machines, and the program itself, then try again. If the message comes back, restart the computer, and don’t start the other program before Whonix.

Then go back to Start the Gateway and click Run again.

The connection wizard does not appear

Open it from the Gateway’s start menu: Applications, then System, then Anon Connection Wizard. Or open a terminal on the Gateway and run:

anon-connection-wizard

It runs in the Gateway’s normal session.

Then go back to Connect to Tor.

The Gateway cannot connect to Tor

Check these, in order:

  1. Your computer’s own internet connection works.
  2. Your computer’s clock is right. Tor can’t connect if it is more than an hour slow or more than three hours fast. Run date -u in a terminal on your computer to see its time in UTC, and compare it with a clock you trust.
  3. Tor may be blocked on your network. Run Anon Connection Wizard again, choose Configure, and use bridges, as Connect to Tor describes.

Then go back to Connect to Tor.

A machine is marked Saved

libvirt saved the machine’s memory to your disk. That happens if you use Save, or if the machine was still running when you logged out or shut down your computer without the settings from step 6. Don’t click Run, because that brings the old session back, which Whonix strongly discourages. Delete the saved state instead, using the machine’s name:

virsh -c qemu:///session managedsave-remove Whonix-Gateway

It prints Removed managedsave image for domain 'Whonix-Gateway'. The machine is now marked Shutoff, and starts afresh, as if it had lost power. Do the same for Whonix-Workstation, or any copy, if it is marked Saved too.

If the settings from step 6 are missing, add them now. Then shut down any machine that is running, close Virtual Machine Manager, and wait two minutes, so that libvirt reads them when it starts again.

Then go back to Start and stop.

What Whonix does and doesn’t do

Whonix hides your IP address. Programs in the Workstation can only reach the internet through Tor, so they can’t reveal where you are, by accident or on purpose.

If you log in to your own accounts, or share details about yourself, Whonix still hides where you are, but not who you are. Read Whonix’s warning page about what it can’t protect you from before you rely on it: https://www.whonix.org/wiki/Warning

In this setup, the virtual machines run under your own account. If harmful software ever broke out of a virtual machine, it would have the same access to your files that you have. Break-outs are rare, but they are a real threat. Updates fix the bugs behind them once they’re found, so keep both machines and your own computer up to date. If you need stronger separation, Whonix recommends Qubes-Whonix for computers that can run it: https://www.whonix.org/wiki/Qubes

By default, Whonix keeps your work on your computer’s disk. If you want nothing left behind when you shut down, Tails starts from a USB stick and forgets everything at shutdown. Whonix’s comparison page shows what each one protects better.

Where to read more

Qemu install script

The script that the QEMU and Whonix guide has you run in steps 1 and 2. It checks whether your computer can run virtual machines at full speed, installs QEMU or updates it, and adds you to the kvm group if you need it. Hover over the code block and press the copy button to take all of it, then save it in your Downloads folder as qemu-install.sh. It is plain text, so read it before you run it; the comments at the top say what each command does. Run it as yourself, not with sudo, and start with bash qemu-install.sh check, which changes nothing.

#!/bin/bash
# qemu-install, v6
#
# Installs QEMU, the program that runs virtual machines, or updates it to
# the newest version your system offers. If you can't use your processor's
# hardware virtualization yet, it also adds you to the kvm group, so
# virtual machines can use it and run at full speed.
#
# There is nothing to edit. Run it as yourself, not with sudo; it asks for
# your password when it needs it.
#
#   qemu-install check     says whether this computer can run fast virtual
#                          machines, and what to fix if it can't; changes
#                          nothing
#   qemu-install install   installs QEMU, or updates it if it is already
#                          there, and adds you to the kvm group if you
#                          need it; apt shows what it will change and asks
#                          first
#
# Run install again whenever you want the newest QEMU. apt downloads only
# what is new or missing and leaves up-to-date packages alone. You only
# need to log out and back in if install ends by saying so.
#
# It installs four packages. qemu-system-x86 runs Intel and AMD virtual
# machines, qemu-system-gui shows them in a window, qemu-utils makes and
# converts disk images, and ovmf lets virtual machines start with UEFI,
# which some operating systems need. apt's optional extras (its
# "recommends") are left out.

set -euo pipefail

## ─── constants ──────────────────────────────────────────────────────

SELF="$(readlink -f "${BASH_SOURCE[0]}")"
readonly SELF

# All four are on the Whonix project's KVM install list, so installing
# Whonix afterwards adds to this set and replaces nothing.
readonly PACKAGES=(qemu-system-x86 qemu-system-gui qemu-utils ovmf)
readonly GROUP='kvm'

## ─── output ─────────────────────────────────────────────────────────

say() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
die() { printf '%s\n' "$1" >&2; exit "${2:-1}"; }

## ─── identity ───────────────────────────────────────────────────────

# Reads line 2 of this file so the filename, header and version output
# cannot drift apart. readlink -f above makes this correct when the script
# is reached through a symlink on PATH.
version() {
    local line
    line="$(awk 'NR==2' "$SELF")" || die "cannot read $SELF"
    printf '%s\n' "${line#\# }"
}

usage() {
    local name
    name="$(basename "$SELF")"
    cat <<EOF
$(version)

installs qemu so you can run virtual machines, or updates it to the
newest version. if you need it, it also adds you to the kvm group so
they run at full speed.

  $name check
      check whether this computer can run fast virtual machines
  $name install
      install or update qemu; apt shows what it will change and asks first
  $name install --yes
      the same, without apt's question
  $name help
      show this help
  $name version
      show which version this is

run it as yourself, not with sudo; it asks for your password when it
needs it. if install ends by telling you to log out and back in, do
that, then run check. run install again any time to get the newest
qemu; it downloads only what has changed.
EOF
}

## ─── preconditions ──────────────────────────────────────────────────

# Refuses root because the kvm group has to go to the person who will run
# the virtual machines, and under sudo id -un answers root instead.
require_environment() {
    local arch
    [[ "$EUID" -ne 0 ]] \
        || die "run this as yourself, not with sudo; it asks for your password when it needs it" 2
    arch="$(uname -m)"
    case "$arch" in
        x86_64) ;;
        *) die "this installs the x86_64 emulator; this host reports $arch" ;;
    esac
    command -v apt-get >/dev/null || die "no apt-get; this script targets Debian and Devuan"
}

# Maps the cpu vendor to the names the firmware setting and the kvm module
# actually use, so the hints below name the right thing on either vendor
# rather than assuming one.
cpu_vendor() {
    local vendor
    vendor="$(awk -F': ' '/^vendor_id/ {print $2; exit}' /proc/cpuinfo)" || vendor=''
    case "$vendor" in
        GenuineIntel) printf '%s\n' 'intel' ;;
        AuthenticAMD) printf '%s\n' 'amd' ;;
        *) printf '%s\n' 'unknown' ;;
    esac
}

# Two different questions. An account is in a group from the moment
# usermod runs; a login session only has the group after a fresh login.
account_in_group() {
    local groups
    groups=" $(id -nG "$1") " || return 1
    [[ "$groups" == *" $2 "* ]]
}

session_in_group() {
    local groups
    groups=" $(id -nG) " || return 1
    [[ "$groups" == *" $1 "* ]]
}

# Returns 0 when the cpu and the kvm module are ready. Each failing branch
# names the next step, because these states are otherwise silent: qemu
# without -enable-kvm runs the guest under emulation and never says why
# it is slow.
check_hardware() {
    local flags vendor setting module
    flags="$(grep -Ec '(vmx|svm)' /proc/cpuinfo)" || flags=0
    vendor="$(cpu_vendor)"

    case "$vendor" in
        intel) setting='intel virtualization technology, or vt-x'; module='kvm_intel' ;;
        amd) setting='svm mode, or amd-v'; module='kvm_amd' ;;
        *) setting='virtualization, vt-x, or svm'; module='kvm_intel or kvm_amd' ;;
    esac

    if [[ "$flags" -eq 0 ]]; then
        warn "no vmx or svm flag in /proc/cpuinfo"
        warn "hardware virtualization is disabled in firmware, or absent from this cpu"
        warn "enter firmware setup at boot; the key varies by vendor, commonly del, f2, f10 or f1"
        warn "the setting is usually under security or advanced, named: $setting"
        return 1
    fi

    if [[ ! -e /dev/kvm ]]; then
        warn "cpu reports the flag but /dev/kvm is missing, so no kvm module is loaded"
        warn "try: sudo modprobe $module"
        warn "then read: sudo dmesg | grep -i kvm"
        warn "if it says disabled by bios, turn this on in firmware setup: $setting"
        return 1
    fi

    return 0
}

# Returns 0 when this login can use /dev/kvm. The failures need different
# fixes, and telling someone to join a group they are already in sends
# them round in a circle.
check_access() {
    local user
    if [[ -r /dev/kvm && -w /dev/kvm ]]; then
        say "hardware acceleration is available"
        return 0
    fi

    user="$(id -un)"
    if session_in_group "$GROUP"; then
        warn "you are in the $GROUP group, but /dev/kvm still does not let you read and write it"
        warn "look at its permissions with: ls -l /dev/kvm"
    elif account_in_group "$user" "$GROUP"; then
        warn "you are in the $GROUP group, but it takes effect only after you log out and back in"
    else
        warn "/dev/kvm exists but you cannot read and write it"
        warn "run '$(basename "$SELF") install' to join the $GROUP group, then start a new login session"
    fi
    return 1
}

## ─── install ────────────────────────────────────────────────────────

# apt-get installs what is missing, upgrades what is out of date and leaves
# the rest alone, reusing packages it has already downloaded. Handing it the
# whole list every time is what makes a later run an update.
install_packages() {
    local assume_yes="$1"
    local rc version
    local -a answer=()

    # apt asks before it changes anything and gives up when nobody can
    # answer; this says so up front instead of after the package-list refresh.
    if [[ "$assume_yes" == 'yes' ]]; then
        answer=(-y)
    elif [[ ! -t 0 ]]; then
        die "no terminal to prompt and --yes was not given; nothing installed or updated" 2
    fi

    command -v sudo >/dev/null || die "no sudo on PATH; cannot install packages"
    sudo -v || die "sudo failed, so nothing was installed"

    say "refreshing package lists..."
    # --error-on=any stops on any list that failed to refresh, so a run that
    # gets past here really has checked for the newest versions.
    if ! sudo apt-get update --error-on=any; then
        warn "apt-get update failed; the lines above name the package source that failed"
        die "check your internet connection, or fix or remove that source, then run this again"
    fi

    say "installing or updating qemu..."
    rc=0
    sudo apt-get install --no-install-recommends "${answer[@]}" "${PACKAGES[@]}" || rc=$?

    # apt-get exits 1 when its question is answered no, and 100 on errors.
    case "$rc" in
        0) ;;
        1) die "apt stopped without changing anything; nothing was installed or updated" ;;
        *)
            warn "apt could not finish; see its messages above"
            die "if apt could not find a package, this copy of qemu-install may be too old for your system; get a newer copy"
            ;;
    esac

    version="$(qemu-system-x86_64 --version)" \
        || die "qemu was installed but does not start; see the error above"
    say "qemu is up to date: ${version%%$'\n'*}"
}

# Changes nothing when this login can already use /dev/kvm, or already has
# the group, because then logging out would fix nothing. Otherwise the
# log-out line is printed whenever the account has the group but this
# login does not yet, including on a second run before logging out.
join_group() {
    local user
    user="$(id -un)"

    if [[ -r /dev/kvm && -w /dev/kvm ]] || session_in_group "$GROUP"; then
        return 0
    fi
    if ! getent group "$GROUP" >/dev/null; then
        warn "group $GROUP does not exist; skipping"
        return 0
    fi
    if ! account_in_group "$user" "$GROUP"; then
        say "adding $user to $GROUP..."
        sudo usermod -aG "$GROUP" "$user" || die "could not add $user to $GROUP"
    fi
    say "log out and back in before $GROUP takes effect"
}

## ─── dispatch ───────────────────────────────────────────────────────

main() {
    local subcommand="${1:-}"
    local assume_yes='no'

    case "$subcommand" in
        ''|help|-h|--help) usage; return 0 ;;
        version|--version) version; return 0 ;;
        check|install) shift ;;
        *) die "unknown argument: $subcommand (try: $(basename "$SELF") help)" 2 ;;
    esac

    while [[ "$#" -gt 0 ]]; do
        case "$1" in
            -y|--yes) assume_yes='yes' ;;
            *) die "unknown flag: $1 (try: $(basename "$SELF") help)" 2 ;;
        esac
        shift
    done

    require_environment

    case "$subcommand" in
        check)
            check_hardware || return 1
            check_access || return 1
            ;;
        install)
            if ! check_hardware; then
                warn "installing anyway; virtual machines will be slow or fail to start until this is fixed"
            fi
            install_packages "$assume_yes"
            join_group
            ;;
    esac
}

main "$@"

Ollama Guide: Set up a local LLM

TLDR. Install with bash install-ollama.sh (verifies sha256, scans for unsafe paths, no sudo, no system writes). Best starter model for a 16 GB CPU-only laptop as of May 2026: gemma3:4b. After install, run ollama serve in one terminal, then in another run ollama pull gemma3:4b && ollama run gemma3:4b. Type to chat, /bye to exit.

A walkthrough for installing Ollama on Devuan and running your first local model, written for someone who has never used it.

Why local LLM

For instance, NYU mathematician Tristan Buckmaster (with Anthropic’s Levent Alpoge) accused OpenAI of racing to finish a Navier-Stokes proof after learning of their unpublished related work, possibly via Codex usage.

What Ollama is

A local LLM runtime. It pulls open-weight models (Llama, Qwen, Gemma, Mistral, and others) from a registry, runs them on your CPU or GPU, and exposes them through a small HTTP API and a command-line tool. Everything stays on your machine: the models, the inference, and your prompts and responses. No accounts, no cloud round-trip.

Does it need the internet? Only to download a model the first time. Once a model is pulled, you can unplug the network and it still works. There is no live web lookup and no built-in dictionary fetched on demand. Everything the model “knows” was baked into it during training and is frozen at the model’s training cutoff, so it can be confidently wrong and has no awareness of events after that date unless you paste the information in yourself.

How it works, briefly. Your text is split into small chunks called tokens. The model is a large set of learned numbers (its parameters) that, given the tokens so far, computes the most likely next token, then the next, and so on. That is the whole mechanism: predict the next token, repeat.

Two components:

  • ollama serve is the daemon. It listens on 127.0.0.1:11434, holds models in RAM, and runs inference.
  • ollama is the command-line client. It talks to the daemon over HTTP.

The daemon has to be running before any other ollama command works.

Install

Save the script to your machine as install-ollama.sh (for example in ~/Downloads/), then run:

bash install-ollama.sh

It fetches the latest Ollama release from GitHub, verifies its sha256 against the published checksum, scans the archive for unsafe paths, and installs to ~/.local/opt/ollama/ (no sudo, no system paths) with a symlink (a small pointer file: running ollama actually runs the real binary it points to) at ~/.local/bin/ollama.

Re-run it any time to upgrade. If the latest version is already installed, it exits without touching anything.

The Ollama binary is self-contained, so you could extract the tarball by hand and skip the script entirely; the official manual steps are at https://docs.ollama.com/linux. The script adds sha256 verification (otherwise you are trusting whatever bytes the network handed you), protection against malicious archive paths, idempotent upgrades, and a clean uninstall path.

If the version-fetch step fails, it is almost always GitHub’s API rate limit (60 requests per hour per IP) or a temporary network blip. Wait an hour and retry, or open https://api.github.com/repos/ollama/ollama/releases/latest in a browser to check.

After installing, you may need to make ollama findable. The installer warns if ~/.local/bin is not on your $PATH. On Devuan this happens the first time you install anything into ~/.local/bin/, because ~/.profile only adds that directory if it already existed when you logged in. Fix it for the current terminal:

export PATH="$HOME/.local/bin:$PATH"

For a permanent fix, log out and back in so ~/.profile picks it up. Then verify:

which ollama
# expect: /home/<you>/.local/bin/ollama
ollama --version

There is no fully automatic way around this short of the script writing into your shell startup files, which it deliberately does not do. Keeping your dotfiles untouched is part of why the install is safe to run and trivial to undo.

First run

Ollama needs two terminals: one for the daemon, one for everything else. The daemon stays in the foreground and prints its log there; you will do all real work in a second terminal.

Terminal 1, start the daemon and leave it running:

ollama serve

It prints a startup log. The success line is Listening on 127.0.0.1:11434. Below it you will see hardware detection; on a laptop without a CUDA or ROCm GPU, look for inference compute id=cpu with your available RAM. Keep this terminal open. Ctrl-C stops the daemon, and if you close this terminal the daemon goes with it.

Terminal 2, check that the daemon is responding:

curl 127.0.0.1:11434
# expect: Ollama is running

If you get connection refused instead, the daemon is not running: go back to Terminal 1 and start ollama serve there. If you see Ollama is running, you are ready to pull a model.

Pull and run your first model

Local LLMs are not in the same class as frontier APIs (Opus/Sonnet, GPT-5, Gemini). The flagship open models that approach frontier quality, such as DeepSeek R1 671B, Llama 3.3 70B, and Qwen 3 235B, need 40 to 400 GB of memory and do not run on laptops at all. (The B is billions of parameters, the internal learned numbers described above; more parameters generally means more capability and more RAM needed to run.) What runs locally on a 16 GB laptop is the smallest models in the open lineup. They are useful for offline work, privacy-sensitive tasks, light coding help, learning, and pulling structured data out of text. They are not a replacement for frontier APIs on hard reasoning or long-context work.

Start here on a 16 GB laptop:

ollama pull gemma3:4b && ollama run gemma3:4b

The pull is about 2.5 GB and takes a few minutes the first time. The run drops you into an interactive chat prompt: type a message, hit Enter, get a response. /bye or Ctrl-D exits the chat.

If you get model 'NAME' not found, try pulling it first, the model was not pulled or the tag is wrong. Run ollama list to see exactly what you have, and remember tags are case-sensitive and the colon matters (gemma3:4b, not gemma3 4b).

The first response after starting a model takes 10 to 30 seconds while the model loads from disk; later responses are immediate. The model stays loaded in RAM for five minutes after you exit, so re-running is instant within that window; after the keep-alive expires, the next run reloads from disk in a few seconds.

Gemma 3’s 4b is the best quality-per-byte at this size in 2026: strong general reasoning, good instruction-following, multilingual. If you want a code-specialized model alongside it, run ollama pull qwen2.5-coder:3b.

Inside the chat, /? lists all slash commands. The ones worth knowing early: /clear resets the conversation so the model forgets prior turns, /set system "..." gives the model a standing instruction for the session, /show parameters dumps the active settings (temperature, context size, and so on), and /save NAME plus /load NAME persist a session to disk.

The model’s working memory is bounded. The default context window is 4096 tokens, roughly 3000 English words. Once a chat or a pasted document exceeds that, the earliest content gets dropped silently and the model starts acting like it forgot what you said. If a long chat starts giving disconnected answers, that is the cause: either /clear and restart with a tighter prompt, or restart the daemon with a bigger window. To work with longer inputs, restart the daemon as OLLAMA_CONTEXT_LENGTH=16384 ollama serve. This costs more RAM; doubling the context roughly doubles the KV-cache footprint. The biggest lever for fitting longer contexts on a constrained machine is quantizing the cache itself: OLLAMA_KV_CACHE_TYPE=q8_0 ollama serve cuts KV memory roughly in half with no detectable quality loss. Combine both for maximum reach.

Picking a different size

Match the parameter count to the RAM you have free at the moment the model loads. Q4_K_M is the default quantization Ollama pulls; the figures below are approximate at that quant.

  • :1b to :2b (0.5 to 2 GB on disk): fit anywhere with 4+ GB free RAM. Fast even on slow CPUs. Useful for sanity checks and routing.
  • :3b to :4b (2 to 3 GB): need 4+ GB free RAM. Comfortable on 8 GB systems. Genuinely useful for chat and light tasks.
  • :7b to :8b (4 to 6 GB): need 8+ GB free RAM. The starting point of capable. Slow on CPU, smooth on GPU.
  • :13b to :14b (8 to 10 GB): need 16+ GB free RAM. Painful on CPU, want a GPU.
  • :30b and up (20+ GB): need 32+ GB RAM or a serious GPU. Do not try on a laptop.

Close your browser and heavy apps before pulling the larger sizes; they consume RAM that could go to the model. If the daemon exits with an out-of-memory error while loading a model, the model is too big for available RAM: close other apps or pick a smaller size from this list.

Current model families (May 2026)

The lineup turns over fast. Browse the full catalog at https://ollama.com/library; the families below are reasonable starting points.

  • llama3.2 (:1b, :3b): Meta’s small models. Solid general-purpose.
  • gemma3 (:1b, :4b, :12b, :27b): Google. The :4b punches above its weight.
  • qwen3 (:0.6b, :1.7b, :4b, :8b, :14b, :30b, :32b): Alibaba. Has a thinking mode for chain-of-thought reasoning. Strong multilingual.
  • qwen2.5-coder (:0.5b through :32b): code-specialized, strong at generating and fixing code.
  • mistral (:7b): well-rounded, good for tool calling.
  • phi4-mini (:3.8b): Microsoft. Dense knowledge per parameter, strong on STEM.

After you’re done

Nothing to clean up. /bye exits the chat but leaves the model loaded in RAM for five minutes (the keep-alive window) so re-running is instant. After that the daemon unloads the model automatically; the model file stays on disk and reloads in a few seconds next time. Ctrl-C on the ollama serve terminal stops the daemon, and model files survive. A reboot clears everything from memory while disk state persists.

Chat history is not something to manage. Ollama’s command-line chat is ephemeral; your /bye discards the conversation and nothing was saved. (Third-party UIs like Open WebUI store history separately if you ever add one.)

The only thing that grows over time is your model cache at ~/.ollama/models/. Each pulled model is hundreds of MB to tens of GB and does not get auto-deleted. Check its size occasionally with du -sh ~/.ollama/models. To remove a model, run ollama list to see the exact names, then ollama rm MODEL.

What to expect on different hardware (May 2026)

Before the numbers, the one idea that explains all of them: how fast a model replies depends mostly on memory bandwidth, which is how quickly the chip can read the model out of RAM for each token it produces. It depends much less on raw compute (FLOPS, floating-point operations per second, the usual headline spec for a processor). A bigger model or slower RAM means slower replies. That is why a small model feels snappy on a laptop while a large one crawls, even on the same chip.

Rule of thumb at Q4: expected output is roughly (RAM bandwidth in GB/s) divided by (model size in GB). A 4 GB model on a 50 GB/s laptop is about 12 tokens/s; on a 1000 GB/s GPU it is about 250 tokens/s.

Reading-speed reference: 15+ tokens/s feels fast (faster than you read), 5 to 15 is fine for chat, 2 to 5 is tolerable for one-shot prompts, and under 2 is painful.

  • Typical 2021-era ultrabook (dual-channel DDR4-3200, around 50 GB/s, CPU-only; an integrated Iris-class iGPU is not usable for Ollama in practice). 1b at 15 to 25 tokens/s, 3b to 4b at 6 to 12, 7b to 8b at 3 to 5, 13b under 2. Practical zone: stay under 4b for interactive use, go to 7b when you can wait.
  • Recent mini-PC APU (Ryzen 9 8945HS class, DDR5-5600, around 90 GB/s, Radeon 780M iGPU). About 1.8x the ultrabook on CPU. 1b at 30 to 50 tokens/s, 3b to 4b at 15 to 25, 7b to 8b at 6 to 10, 13b to 14b at 3 to 5, 30b at 1 to 2 (needs a 96 GB RAM option). ROCm on the 780M is bandwidth-limited by shared system RAM, so gains over CPU are modest; the real upgrade is an external NVIDIA GPU over an Oculink port. The install script is identical on both machines: same x86_64 Linux, same tarball.
  • Apple Silicon (M-series, unified memory). M1/M2 base: 7b at 15 to 25 tokens/s. M3/M4 Pro/Max: 7b at 30 to 50, 13b at 15 to 25. M-Max with 64+ GB unified memory: 70b at 8 to 12.
  • Discrete NVIDIA (CUDA, 500 to 1000 GB/s). RTX 3060 12 GB: 7b at 40 to 60 tokens/s. RTX 4090 24 GB: 7b at 100 to 150, 13b at 40 to 80, 32b at 15 to 25.

Day-to-day commands

ollama pull MODEL          # download (or re-fetch latest tag) without running
ollama run MODEL "prompt"  # one-shot prompt
ollama run MODEL           # interactive chat
ollama list                # what's installed locally
ollama ps                  # what's loaded in RAM right now
ollama stop MODEL          # unload from RAM (keep on disk)
ollama rm MODEL            # delete from disk
ollama show MODEL          # parameters, template, size
ollama --help              # full command reference

Running ollama pull MODEL on a model you already have re-fetches it if the registry’s latest tag has moved; that is how you update models.

Keeping the daemon running

The ollama serve terminal approach is fine for trying things out, but you will want something better long-term. Pick the option that matches how you use the machine.

One-off, when needed. Run ollama serve in a terminal, use it, Ctrl-C when done. Nothing to set up. Best for first exploration and intermittent use.

Laptop, daily use, graphical session. Add an XFCE autostart entry so the daemon comes up at login:

mkdir -p ~/.config/autostart
cat > ~/.config/autostart/ollama-serve.desktop <<EOF
[Desktop Entry]
Type=Application
Name=Ollama daemon
Exec=sh -c 'exec "\$HOME/.local/bin/ollama" serve >/tmp/ollama.log 2>&1'
NoDisplay=true
EOF

The full path to the binary in Exec= avoids PATH-resolution issues at autostart time. Logs go to /tmp/ollama.log and survive until reboot. To stop the daemon, run pkill -f 'ollama serve'. To remove the autostart, delete the .desktop file.

SSH, headless, or always-on. Run the daemon inside a terminal multiplexer so it survives after you disconnect. A terminal multiplexer keeps a shell session alive on the machine even after you close the connection, so a long-running process keeps going and you can reattach to it later. tmux is the modern default; screen is the older one and is preinstalled more often.

tmux new -d -s ollama 'ollama serve'   # start the daemon in a detached session named "ollama"
# reattach later with: tmux attach -t ollama
# detach again without killing it: press Ctrl-b, then d

If tmux is not installed, sudo apt install tmux, or use the autostart option above instead.

Multi-user box or server-grade always-on. Write a sysvinit script in /etc/init.d/ollama and enable it with update-rc.d ollama defaults. That is out of scope here; for a personal machine, the autostart or tmux options cover everything.

Where things live

  • Binary symlink: ~/.local/bin/ollama points to ~/.local/opt/ollama/bin/ollama
  • Runtime libraries: ~/.local/opt/ollama/lib/ollama/
  • Models and manifests: ~/.ollama/models/
  • HTTP API: 127.0.0.1:11434 (no auth, localhost-only)
  • Daemon logs: stderr of the terminal running ollama serve, or /tmp/ollama.log if you used the autostart entry above

Uninstall with bash install-ollama.sh --uninstall. To also delete cached models and any autostart entry, run rm -rf ~/.ollama and rm -f ~/.config/autostart/ollama-serve.desktop.

Going further

The interactive chat is the easiest way in, but it is not where local Ollama shines hardest. The command line’s real advantage over a web chat is that it plugs into your other tools.

Here is the idea, in plain terms. Most programs read text in and write text out as streams. You can feed one program’s output straight into ollama run as the prompt, and capture the model’s answer as plain text that flows into your next command. No copy-paste, no upload dialog, no switching to a browser tab.

# Summarize a file: the file's text becomes the prompt, the summary prints to your terminal
cat README.md | ollama run gemma3:4b "summarize in 3 bullets"

# Write a commit message from staged changes: the diff becomes the prompt
git diff --cached | ollama run gemma3:4b "write a conventional commit message"

# Explain an error: the command's error output becomes the prompt
some-command 2>&1 | ollama run gemma3:4b "what does this error mean?"

You can also save the answer to a file instead of just printing it, by redirecting with >:

git diff --cached | ollama run gemma3:4b "write a commit message" > msg.txt

For these cases this beats a frontier web chat: no frontier model is needed, and the answer flows straight into your shell pipeline.

Clipboard

The X11 standard works in any terminal: select text with the mouse, then middle-click to paste the primary selection. For the Ctrl-V clipboard proper, xfce4-terminal uses Ctrl-Shift-C to copy and Ctrl-Shift-V to paste. For copying program output directly, install xclip (sudo apt install xclip) and pipe into it:

ollama run gemma3:4b "explain rsync flags" | xclip -selection clipboard

There is no built-in copy-last-response inside the interactive chat; ollama run just writes plain text to the terminal with no scrollback of its own. Two workarounds. When you know upfront you want the output, use the one-shot piping pattern above (pipe to xclip or redirect to a file). After the fact, run the session inside tmux and use its copy mode: press Ctrl-b then [ to enter copy mode, move to the start of what you want, press Space to begin selecting, move to the end, press Enter to copy into tmux’s buffer, then push that to the system clipboard with tmux save-buffer - | xclip -i -selection clipboard. Neither is strictly better; the piping route is simpler when you plan ahead, tmux copy is for grabbing something you did not plan to capture.

Tools that wrap Ollama

The Ollama command line is the floor, not the ceiling. The ecosystem builds richer interactions on the same daemon.

  • llm (Simon Willison): a pipeable CLI with templates, plugins, and conversation logging. Apache-licensed, widely used among Linux and command-line developers, and speaks to many backends with Ollama as one of them.
  • shell-gpt (sgpt): turns a natural-language description into a shell command, then lets you review and run it.
  • Open WebUI: a self-hosted web interface on top of Ollama with chat history, document Q&A (retrieval), file upload, and system-prompt presets. The “web AI but local” surface. It does not modify your filesystem; it is just a chat surface.
  • aider: a terminal AI pair-programmer that reads, edits, and commits files in a git repo through conversation. Thin and open, with the smallest attack surface of the agentic tools. Built for frontier models; works with Ollama, but the quality gap shows on multi-step coding.
  • continue.dev: a VSCode/JetBrains extension with inline autocomplete plus chat. Works with Ollama, but autocomplete needs sub-second responses, which means a GPU.
  • Cline: an agentic VSCode extension that reads files, edits them, and runs terminal commands. Same hardware constraints as aider and continue.
  • ollama launch: a built-in subcommand in recent versions that wires Ollama into coding agents and assistants automatically. Supported integrations include Claude Code, Codex, Copilot CLI, Droid, and OpenCode; ollama launch claude points Claude Code at your local Ollama, and ollama launch openclaw sets up OpenClaw as a personal assistant across WhatsApp, Telegram, Slack, and Discord.

Which to choose:

  • Just chat and piping, nothing extra to install: ollama run directly.
  • A scriptable CLI with logging and templates: llm. This is what most Linux and command-line developers reach for, and it is fully open source.
  • Natural-language to shell commands: shell-gpt.
  • A local web interface with document Q&A: Open WebUI.
  • Editing files in a git repo you trust: aider (thin, open, smallest attack surface).
  • Inline help inside an editor: continue.dev or Cline, once you have a GPU.

For a sovereignty-minded, CPU-only setup, the most open and most self-contained picks are llm and aider for the command line and Open WebUI for a browser surface; all three are permissively licensed and run entirely on your machine.

Best way forward on a CPU-only laptop

A 16 GB CPU-only laptop does a couple of things well and several things poorly. Lean into the former:

  1. Install xclip immediately. It unlocks the piping and capture-to-clipboard workflow.
  2. Use one-shot piping as your daily-driver pattern. It is where a local LLM is most useful and least sluggish.
  3. Install Open WebUI if you want chat-with-files or document Q&A. Slow on CPU but functional, and installable with pipx.
  4. Skip the agentic editor tools (continue.dev autocomplete, aider, Cline) until you have a GPU. The latency makes them frustrating rather than helpful.
  5. For tasks that need frontier quality (hard reasoning, multi-step coding, long-context analysis), use a frontier web AI. Local Ollama complements it, it does not replace it.

When you later move to a mini-PC with an external GPU, revisit item 4: continue.dev’s autocomplete becomes responsive, aider and Cline become genuinely useful for multi-file changes, and the latency-bound tools shift from frustrating to fine.

Sovereignty-minded alternatives

Ollama is a friendly wrapper around llama.cpp, which is the actual inference engine underneath. Most people never need to go below Ollama. You only drop down to raw llama.cpp if Ollama’s defaults block something you specifically need: a model file too large for it to load, a compression setting (quantization) it does not expose, or fine control over how the model picks each word (samplers). If none of those bite you, skip this section.

If you do drop down, the interface is llama-server, a daemon similar to ollama serve but with the full llama.cpp surface, and a companion tool llama-swap handles switching between several loaded models. It speaks the same OpenAI-compatible HTTP API, so anything pointed at 127.0.0.1:11434 for Ollama can usually be repointed at llama-server. The Bitcoin-aligned framing is direct: run your own node, run your own model; both validate inputs under rules you set, and neither asks permission.

If you run StartOS (Start9’s sovereign-server platform, a common home for a Bitcoin node, Nostr relay, or private cloud), Ollama is available as a dedicated package: the current ollama-startos release is v0.21.0 (April 2026), shipping generic x86_64 and aarch64 builds plus a ROCm GPU build, and a companion package bundles Ollama with Open WebUI. Start9’s own guidance on running AI next to Bitcoin services is to keep them on separate machines; the cautious move is dedicated hardware. Bitcoin Knots users (Luke Dashjr’s more conservative Bitcoin Core fork) sit in the same sovereign-stack orientation: pair a Knots node with Ollama on separate hardware over Tailscale or similar, and treat the AI machine as untrusted relative to your node.

How does StartOS packaging compare to this script, and why pick one over the other? StartOS gives you a turnkey, GUI-managed service with automatic updates and backups, packaged ROCm GPU support, and Open WebUI bundled, running always-on alongside your other sovereign services. This script gives you a local install on the laptop you already own, with no extra hardware, no Docker, and no server OS to maintain: a single sha256-verified file you can audit, that writes nothing outside its own tree and is trivial to undo, and that is portable across any x86_64 Linux box. If you already run StartOS on a separate machine, use its package. If you want a local model on your working laptop without standing up a server, this script is the right tool. They are complementary, not competing.

For tasks where your local hardware cannot fit the model you need, the sovereignty-respecting alternative to OpenAI or Anthropic is not another centralized service; it is Nostr’s Data Vending Machine layer (NIP-90). You publish a job request to Nostr relays, providers compete to fulfill it, and payment goes over Lightning. No account, no identity tied to a payment method, no Big Tech logging your prompts into a training corpus. Routstr provides a drop-in OpenAI-compatible client that routes over this layer, with Tor and SOCKS5 support; you pay in sats per call and get inference from whichever provider bid lowest. For Bitcoin and Nostr-aligned developers this is the analog of a Lightning-paid VPN over a Big Tech cloud: same capability, different trust model.

A caveat worth internalizing: open-weights is not open-source. The model files you pull with ollama pull are the trained weights (Llama, Qwen, Gemma, DeepSeek, all of them). What you do not get is the training code, the training data, or the recipe to reproduce them. You cannot audit a downloaded model for backdoors, cannot verify it is not acting in its creator’s interest under certain triggers, and cannot rebuild it from sources. The script’s sha256 check proves the file matches what upstream published; it does not prove the file behaves as advertised under all inputs.

A handful of models go further and publish weights, training data, and code together, which is the actual open-source bar. They lag the open-weights frontier on raw capability, but they are the ones you can fully inspect:

  • OLMo (Allen Institute for AI): the most prominent, now at OLMo 3 with 7B and 32B models plus full datasets, code, and training logs. https://allenai.org/olmo
  • Pythia (EleutherAI): one of the earliest fully transparent model suites, Apache-2.0, with training data, code, and checkpoints; dated in quality but the benchmark for reproducibility. https://github.com/EleutherAI/pythia
  • LLM360 (Amber, Crystal, K2): releases all training code, data, intermediate checkpoints, and analyses. https://www.llm360.ai
  • M-A-P Neo and DCLM baseline models: further fully-open efforts that AI2 itself names alongside its own.

Which to pick: to actually run something useful and fully open, OLMo 3; to study or reproduce a training run, Pythia or LLM360; for everything else you pull through Ollama, assume it is open-weights, not open-source, and choose models from teams whose incentives you understand. The “not your keys, not your coins” frame extends here: not your training, not your model.

What’s possible on bigger hardware

A few developments worth knowing for when you upgrade. They change little for a 16 GB CPU-only laptop, but they reshape what a mini-PC with an external GPU or a 64 GB+ setup can do.

MoE (Mixture of Experts) architectures. Qwen3-30B-A3B has 30B total parameters but activates only 3B per token. It runs at roughly 3B speed while drawing on near-30B knowledge. Qwen3-Coder-Next is 80B with 3B active, comparable to dense models with 10 to 20 times more active parameters, running on 46 GB of RAM or VRAM. The full weights still have to fit, so “tiny RAM” is true relative to dense equivalents, not in absolute terms.

Unsloth dynamic quantization. The Han brothers’ open-source work compresses large models with minimal quality loss by using a different bit-depth per layer. DeepSeek-R1’s 671B model compresses from 715 GB to 162 GB at 1.66-bit dynamic. This is the breakthrough that makes 30B+ models viable on prosumer hardware. Their docs at unsloth.ai are the reference.

The current local-agent stack. As of May 2026 this has stopped being a prediction and become real. Nous Research’s Hermes Agent is an open-source (MIT) agent framework that crossed 140,000 GitHub stars in under three months and is currently the most-used agent on OpenRouter; it is built to run locally and stay on all day. It pairs with Alibaba’s Qwen 3.6 models, where the 27B dense model matches the accuracy of 400B-class predecessors at one-sixteenth the size, and the 35B model runs in roughly 20 GB while surpassing 120B-parameter models. NVIDIA’s DGX Spark (shipping since October 2025, a desktop box with 128 GB of unified memory) and RTX-class GPUs are the hardware these are tuned for. On a 16 GB CPU-only laptop you will mostly run distilled 4 to 8B versions; on a mini-PC with a GPU you can reach the 27 to 35B Unsloth-quantized variants and unlock most of the headline capability.

Chinese open-weight labs. DeepSeek, Qwen (Alibaba), Kimi (Moonshot), GLM (Zhipu). Faster release cadence than US labs, and competitive on specific benchmarks; Qwen3-Coder-480B claims rough parity with Claude Sonnet-4 on Aider Polyglot. These are not more powerful than the closed frontier in general, but they are strong in specific domains (coding, math, multilingual) at no per-token cost if you can fit them.

Security and trust model

The sha256 verification in the install script covers transit and CDN-layer integrity; it does not cover the trust you place in the agentic tools you point at your daemon. Worth knowing what has actually happened in 2025 and 2026 before pointing any of them at sensitive code.

Anthropic’s Claude Code has had a notable security track record. CVE-2025-59536 (CVSS 8.7, October 2025) allowed arbitrary shell command execution via Hooks configuration when Claude Code was started in an attacker-controlled directory. CVE-2026-21852 (January 2026) let malicious repositories exfiltrate Anthropic API keys before the trust prompt appeared. CVE-2025-54794 and CVE-2025-54795 (“InversePrompt”) added a path-restriction bypass and command injection via whitelisted commands. CVE-2025-66479 was a sandbox bypass where allowedDomains: [] was misread as “allow everything”. A SOCKS5 sandbox bypass affected around 130 versions over 5.5 months and was silently patched in v2.1.90 with no CVE and no mention in release notes; that is the one worth flagging, because the disclosure pattern was opaque. A separate event in March 2026 was the leak of around 512K lines of Claude Code’s TypeScript source via npm, which triggered more researcher review and surfaced a deny-rule bypass via subcommand-limit overflow.

The through-line is repository-controlled configuration: .claude/settings.json, hooks, MCP configs, and environment variables read from cloned repos. These run with your privileges before the model is even invoked, so prompt-injection defenses do not help. Open an untrusted repo, get owned.

What that means in practice for a sovereignty-minded workflow:

  • Never run any agentic AI tool in a directory you do not fully trust.
  • Pointing an agent at local Ollama does not shrink the local attack surface; the bugs are in the agent’s configuration parsing, not its inference backend.
  • The simpler agentic tools have smaller attack surfaces. aider is a thin layer over git and a model, with much less surface than a full settings/hooks/MCP system.
  • Sandbox aggressively. Run agentic tools inside a container or VM if you will point them at unfamiliar repos. Bubblewrap, Firejail, or a full VM all work.
  • Treat configuration files in cloned repos as executable code. Audit them before opening.

The conservative recommendation: use ollama run directly for chat and one-shot piping, reach for aider when you need file edits on repos you wrote yourself, and avoid running configuration-driven agents on untrusted codebases regardless of which backend they point at.

Troubleshooting

Most errors are handled inline in the sections above, right where they come up. The two that do not have a natural home:

  • Error: listen tcp 127.0.0.1:11434: bind: address already in use: a daemon is already running, possibly a stale one. Find and stop it with pkill -f 'ollama serve', then start fresh.
  • ollama: command not found: ~/.local/bin/ is not on your $PATH. See the PATH fix in the Install section.

Further reading

  • Model catalog: https://ollama.com/library
  • Docs: https://docs.ollama.com
  • Manual Linux install: https://docs.ollama.com/linux
  • HTTP API reference: https://docs.ollama.com/api

Ollama install script

#!/usr/bin/env bash
# install-ollama
#
# Standalone script. Downloads the latest Ollama Linux x86_64 release
# tarball from GitHub, verifies its sha256 against the upstream
# sha256sum.txt, installs it user-space at ~/.local/opt/ollama/, and
# symlinks the binary into ~/.local/bin/. No service is installed; run
# `ollama serve` manually when needed.
#
# Usage:
#   bash install-ollama.sh              install or upgrade to latest
#   bash install-ollama.sh --uninstall  remove the install tree and symlink
#
# Security note: Ollama does not publish GPG signatures for release
# tarballs (see ollama/ollama#1313, #2323). Verification is sha256-only
# against the same release's published sha256sum.txt, both fetched
# over HTTPS from github.com. This rules out transit corruption and
# CDN-layer tampering but does not protect against a compromise of the
# upstream release pipeline. Switch to gpgv if upstream starts signing.

set -euo pipefail
umask 022  # ensure install dir and files are owner-write only

INSTALL_DIR="$HOME/.local/opt/ollama"
BIN_DIR="$HOME/.local/bin"
SYMLINK="$BIN_DIR/ollama"
TARGET="$INSTALL_DIR/bin/ollama"

say() { printf '%s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }

# DL_TMP is script-scope so the EXIT trap can clean on any exit path
# including die. Initialized empty; cleanup no-ops until it's set.
DL_TMP=""
cleanup() {
    [ -n "$DL_TMP" ] && rm -rf "$DL_TMP"
    return 0
}
trap cleanup EXIT

install_ollama() {
    local VERSION_FILE="$INSTALL_DIR/.version"
    local RELEASES_URL="https://github.com/ollama/ollama/releases"
    local API_URL="https://api.github.com/repos/ollama/ollama/releases/latest"
    local TARBALL="ollama-linux-amd64.tar.zst"
    local SUMS="sha256sum.txt"
    local VERSION INSTALLED BASE EXPECTED ACTUAL

    case "$(uname -m)" in
        x86_64) ;;
        *) die "unsupported arch: $(uname -m) (script targets x86_64)" ;;
    esac

    command -v wget      >/dev/null || die "wget required but not installed"
    command -v sha256sum >/dev/null || die "sha256sum required but not installed"
    command -v tar       >/dev/null || die "tar required but not installed"
    command -v zstd      >/dev/null || die "zstd required but not installed (apt install zstd)"

    # GitHub's v3 REST API is the documented stable contract for release
    # metadata (vs. parsing the HTML redirect from /releases/latest, which
    # is undocumented). Rate-limited at 60/hour per IP unauthenticated,
    # ample for a personal installer.
    # --https-only enforces TLS across the entire redirect chain (release
    # assets redirect through release-assets.githubusercontent.com → S3).
    say "fetching latest version..."
    VERSION=$(wget --https-only -qO- "$API_URL" \
        | grep -m1 '"tag_name"' \
        | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' \
        | tr -d '\r\n') || true
    [ -n "$VERSION" ] || die "version fetch failed (API_URL=$API_URL)"
    [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+){1,3}([-+a-zA-Z0-9.]*)?$ ]] \
        || die "version string failed validation: '$VERSION'"
    say "latest version: ${VERSION}"

    if [ -f "$VERSION_FILE" ]; then
        INSTALLED=$(cat "$VERSION_FILE")
        if [ "$INSTALLED" = "$VERSION" ]; then
            say "ollama $VERSION already installed at $INSTALL_DIR"
            return 0
        fi
        say "upgrading from $INSTALLED to $VERSION"
    fi

    DL_TMP=$(mktemp -d) || die "mktemp -d failed"
    BASE="$RELEASES_URL/download/$VERSION"

    say "downloading checksums..."
    wget --https-only -nv -P "$DL_TMP" "$BASE/$SUMS" || die "$SUMS download failed"

    say "downloading $TARBALL..."
    wget --https-only -nv -P "$DL_TMP" "$BASE/$TARBALL" || die "$TARBALL download failed"

    # sha256sum.txt entries are typically `<hash>  ./<file>` but the
    # leading `./` isn't universal; match either form.
    say "verifying tarball hash..."
    EXPECTED=$(awk -v f="$TARBALL" '$2==f || $2=="./" f {print $1}' \
        "$DL_TMP/$SUMS" | head -1) || true
    if ! [[ "$EXPECTED" =~ ^[a-f0-9]{64}$ ]]; then
        say "no hash found for $TARBALL; $SUMS contents follow:"
        cat "$DL_TMP/$SUMS" >&2
        die "hash extraction failed"
    fi
    ACTUAL=$(sha256sum "$DL_TMP/$TARBALL" | awk '{print $1}')
    [ "$EXPECTED" = "$ACTUAL" ] \
        || die "tarball hash mismatch (expected $EXPECTED, got $ACTUAL)"

    # Tarslip defense. Sha256 proves the tarball matches what upstream
    # published; it does not prove upstream's release pipeline wasn't
    # compromised. Reject any entry with absolute paths or `..` traversal
    # before extracting. GNU tar 1.30+ also rejects these by default; this
    # is explicit defense-in-depth that doesn't depend on tar version.
    say "validating archive paths..."
    if tar --zstd -tf "$DL_TMP/$TARBALL" \
        | grep -qE '(^/|(^|/)\.\.(/|$))'; then
        die "archive contains absolute or .. paths; refusing to extract"
    fi

    # Clean any prior install tree so partial/older extractions can't
    # mix with the new one. The version-match path above is the no-op;
    # this is the upgrade path.
    say "installing to $INSTALL_DIR..."
    rm -rf "$INSTALL_DIR" || die "stale install dir cleanup failed"
    mkdir -p "$INSTALL_DIR" || die "mkdir $INSTALL_DIR failed"
    tar --zstd -xf "$DL_TMP/$TARBALL" -C "$INSTALL_DIR" --no-same-owner \
        || { rm -rf "$INSTALL_DIR"; die "extraction failed"; }

    [ -x "$TARGET" ] \
        || { rm -rf "$INSTALL_DIR"; die "extracted tree missing bin/ollama"; }

    printf '%s\n' "$VERSION" > "$VERSION_FILE" \
        || die "version marker write failed"

    say "creating symlink at $SYMLINK..."
    mkdir -p "$BIN_DIR" || die "mkdir $BIN_DIR failed"
    ln -sf "$TARGET" "$SYMLINK" || die "symlink failed"

    say "ollama $VERSION installed; run 'ollama serve' to start the daemon"

    # On Devuan, ~/.profile adds ~/.local/bin to PATH only if the dir
    # already existed at login. First-time installs hit this and `ollama`
    # comes back as command-not-found until next login.
    case ":$PATH:" in
        *":$BIN_DIR:"*) ;;
        *) say "warning: $BIN_DIR not in \$PATH — log out and back in, or run 'export PATH=\"\$HOME/.local/bin:\$PATH\"'" ;;
    esac
}

uninstall_ollama() {
    # Only remove the symlink if it points to our install tree, so a
    # manually-placed binary at $SYMLINK isn't clobbered.
    local found=0

    if [ -L "$SYMLINK" ] && [ "$(readlink "$SYMLINK")" = "$TARGET" ]; then
        say "removing symlink $SYMLINK"
        rm -f "$SYMLINK"
        found=1
    fi

    if [ -d "$INSTALL_DIR" ]; then
        say "removing install dir $INSTALL_DIR"
        rm -rf "$INSTALL_DIR"
        found=1
    fi

    [ "$found" = 1 ] || die "nothing to uninstall (no install dir at $INSTALL_DIR)"

    say "done. ~/.ollama (models) and any autostart entry are left alone;"
    say "remove them by hand if you want them gone."
}

[ "$(id -u)" -ne 0 ] || die "don't run as root; this is a user-space install"

case "${1:-}" in
    "")          install_ollama ;;
    --uninstall) uninstall_ollama ;;
    *)           die "unknown argument: $1 (use --uninstall, or no args to install)" ;;
esac

Secure AI Workstation Setup Guide

This guide walks you through building a computer that runs AI chat models entirely by itself, with no internet connection, ever. You will get it assembled, install Linux on it, shut off every way it could reach a network, and run large language models on it with a free program called llama.cpp. Your questions, your documents and the model’s answers never leave the machine.

It is written for someone who has never done anything like this: never installed Linux, never used a terminal, never built a computer or set up a home server. If you can use a web browser and follow instructions carefully, you can do this. Every new word is explained the first time it appears, every command is shown exactly, and after every command the guide tells you what you should see, including messages that look alarming but are normal.

Prices are Indian street prices seen between July and October 2026. They will change.

Contents

  1. Before you start
  2. Basics
  3. What this protects against
  4. Hardware
  5. The connected machine
  6. Transfer drives and rules
  7. Firmware settings
  8. Install Devuan
  9. Cut the network for good
  10. Offline package updates
  11. NVIDIA driver and CUDA
  12. Build llama.cpp
  13. Models
  14. Run it
  15. Maintenance
  16. Final checks
  17. Troubleshooting

0. Before you start

0.1 What you are building

Two computers and two USB drives:

  • The box is the new computer you will build. It runs the AI. It never connects to any network: not once, not even for a minute. This is called an air gap, because there is a gap of air between the box and every network.
  • The connected machine is a second, ordinary computer that does have internet. An old laptop is fine. It downloads things for the box.
  • The IN drive is a USB drive that carries downloads from the connected machine to the box.
  • The OUT drive is a second USB drive that carries small requests from the box back to the connected machine.
     internet
        |
 connected machine  ---- IN drive ---->  the box
   (old laptop)     <--- OUT drive ----  (no network, ever)

Everything the box ever gets, it gets on the IN drive, and the box checks every file before it uses it. Doing those checks properly is most of what this guide teaches.

0.2 Is this for you

Be honest with yourself about three things.

  • Time. For a first-timer, expect two or three weekends: one for buying and assembling the hardware, one for installing Linux and cutting off the network, and one for updates, the NVIDIA driver, llama.cpp and your first model. The longest parts are section 9 (updates without internet) and section 11 (building llama.cpp). They are not hard, but they are long and need care.
  • Patience with detail. Most steps are typing a command exactly and comparing the result with what the guide shows. A wrong letter usually just gives an error that you can fix. A few steps erase a drive; each of those is marked with a warning. Slow down at those.
  • Money. About ₹45,000 to ₹60,000 for the computer without the graphics card, plus about ₹1,00,000 for a used RTX 3090 graphics card. Section 3 lists every part.

You do not need to know Linux, programming or networking. You do need a computer you already use for web browsing (Windows, Mac or Linux), a smartphone with a web browser, and an old or cheap used laptop for the connected machine (section 4).

0.3 How to use this guide

  • Read the whole guide once before you buy anything. You do not need to understand every command on the first read. You do need to know what is coming.
  • Then work through it in order. Each section depends on the ones before it.
  • Keep the guide open on another screen. The box has no internet, so you cannot open this guide on it. Keep it open on the connected machine or your phone, or print sections 5, 8 and 9, which you will use most.
  • On the box you type every command yourself. You cannot copy and paste from this guide into the box, because the guide is on a different machine. Most commands are short. On the connected machine you can copy and paste.
  • Check after every command. The guide shows what you should see. If you see something else, stop and look in section 16 before going on.
  • Do not skip the checks. They are the security. A box built without them is just a computer without Wi-Fi.

1. Basics

This section covers the few things everything else depends on. Read it now, and come back to it whenever a later step uses something you have forgotten.

1.1 Words used in this guide

Words specific to this build:

  • The box: the air-gapped AI computer you are building.
  • The connected machine: the ordinary internet-connected Linux computer that downloads files for the box (section 4).
  • IN drive: the USB drive that carries files from the connected machine to the box.
  • OUT drive: the USB drive that carries files from the box to the connected machine.
  • Crossing: one trip of one drive from one machine to the other.
  • Phone check: comparing a fingerprint of a file that the box worked out itself (see checksum below) with the one the file’s publisher shows on their website, opened on your phone. It is the one check a hacked connected machine cannot fake, because the connected machine is not involved: the box computes one side and your phone shows the other.

General computer words:

  • Linux: a free operating system, like Windows or macOS. Devuan is the version of Linux (a distribution) this guide uses. Debian is a close relative of Devuan; most things that work on one work on the other.
  • Terminal: a window where you type commands instead of clicking. Section 1.2 shows how to use one.
  • Command: one instruction you type into the terminal and run by pressing Enter.
  • Root, sudo: root is the all-powerful administrator account. Putting sudo in front of a command runs that one command as root. It asks for your password.
  • File, folder, path: a path is the address of a file or folder, like /home/john/notes.txt. Folder names are separated by /. The sign ~ is short for your own home folder, so for the user john, ~/notes.txt and /home/john/notes.txt are the same file.
  • Drive, partition, filesystem: a drive is a physical storage device, such as a USB stick or an SSD. A partition is a section of a drive. A filesystem is the way files are organised inside a partition so that a computer can show them as files and folders. The transfer drives in this guide deliberately have no filesystem (section 5).
  • Mount: to connect a partition’s filesystem so that its files show up in the file manager. Normal computers do this by themselves when you plug in a USB stick. The box never does it.
  • Device name: Linux names drives like /dev/sda or /dev/sdb (USB drives and ordinary SSDs) and /dev/nvme0n1 (the fast kind of internal SSD). Partitions get a number added: /dev/sdb1 is the first partition on /dev/sdb.
  • Firmware, BIOS, UEFI: the small program built into the motherboard that runs the moment you press the power button, before any operating system. Its setup screen is where you switch hardware on and off (section 6). BIOS and UEFI are two generations of it, and people use the words interchangeably.
  • Driver, kernel, kernel module: a driver is the software that lets the operating system use a piece of hardware. The kernel is the core of Linux. Most Linux drivers are kernel modules: pieces that get loaded into the kernel when needed.
  • Package, apt, repository, mirror: Linux software comes in packages. apt is the tool that installs them. Normally it downloads them from a repository, a large online collection run by Devuan. A mirror is a copy of a repository. The box gets its own small offline mirror (section 9).
  • Checksum, hash, SHA256: a string of letters and numbers calculated from a file’s contents, like a fingerprint. Change even one byte of the file and the checksum changes completely. SHA256 is the method used in this guide; its checksums are 64 characters long. If your checksum of a file matches the publisher’s, you have exactly the file they published.
  • Signature, key, fingerprint: a digital signature proves who published a file. The publisher signs with their secret key, and anyone can check the signature with the matching public key. A key’s fingerprint is a long code that identifies the key. This guide uses one signature check, in section 7.1.
  • Encryption, passphrase: an encrypted disk looks like random noise to anyone without the passphrase. You type the passphrase each time the box starts.
  • GPU, VRAM: the GPU is the graphics card, here an NVIDIA RTX 3090. It does the AI work. VRAM is the graphics card’s own memory, 24 GB on the 3090. The AI model has to fit into it.
  • Model, GGUF, quantization: a model is the AI itself: one big file of numbers, usually 5 to 40 GB. GGUF is the file format llama.cpp reads. Quantization shrinks a model by storing its numbers less precisely, so that a bigger model fits on the card. Names like Q4_K_M say how much it was shrunk (section 12).
  • Source code, compile, build: source code is a program written as text by programmers. Compiling or building turns it into a program the computer can run. You will build llama.cpp on the box in section 11.
  • Git, commit, tag: git is a tool programmers use to keep the full history of their source code. Each saved state is a commit, identified by a 40-character code (its hash). A tag is a name given to one commit, such as a release version.
  • Localhost, port: 127.0.0.1, also called localhost, means “this same computer”. A port is a numbered door on it. The AI’s chat page runs at 127.0.0.1:8080: door 8080 on the box itself, which nothing outside the box can reach.

1.2 Using a terminal

Opening one. On Devuan’s desktop (it is called Xfce), click Applications at the top left of the screen, then Terminal Emulator. On a computer with a different desktop, search the applications menu for “Terminal”.

A window opens with a line ending in $, for example:

john@airgap:~$

This is the prompt. It shows your user name, the computer’s name and the folder you are in (~, your home folder). It means the terminal is waiting for a command.

Running a command. Type it exactly, then press Enter. Spelling, capital letters, spaces and punctuation all matter: ls -l and ls -L are different commands. When the command finishes, the prompt comes back. Some commands print nothing at all when they succeed. That is normal, and the guide says when to expect it.

Boxes. Commands, file contents and expected output all appear in boxes like this one:

ls ~

The sentence before each box says which it is: commands to run, text to type into a file, or output to compare with. Type only what is inside the box. Some viewers show a small label such as sh or text at the top of a box: never type the label, and never type the $ of the prompt.

When a box holds several lines, run them one at a time, in order, and check each one. Two kinds of box are exceptions and must go in as one piece: a command spread over several lines with a \ at the end of each line except the last, and a loop that starts with for or while and ends with done. Type those line by line, pressing Enter at the end of each line; the terminal shows > at the start of each continuation line until the command is complete. On the connected machine you can paste such a box in one go, as long as it contains no placeholders.

Copy and paste in a terminal (connected machine only). Copy from the browser as usual with Ctrl+C. Paste into the terminal with Ctrl+Shift+V, not Ctrl+V. Inside a terminal, Ctrl+C does not copy: it stops whatever command is running.

sudo and your password. The first time you use sudo in a terminal window, it asks:

[sudo] password for john:

Type your user password and press Enter. Nothing appears while you type, not even dots. That is normal and deliberate. If you mistype, press Enter and it asks again. After that, sudo remembers your password for a few minutes in that window.

Placeholders. Anything in angle brackets, like <dev> or <date>, is a placeholder. Replace the whole thing, brackets included, with your own value. For example, if the guide says

lsblk /dev/<dev>

and your drive is sdb, you type lsblk /dev/sdb. Typing the brackets gives an error such as syntax error near unexpected token, which only means a placeholder was not replaced.

The two placeholders you will meet most:

  • <date> is a folder named after the day of a crossing, for example 2026-11-01. Every crossing gets its own new folder. For a second crossing on the same day, add a letter: 2026-11-01b.
  • <stamp> is the name of the folder that receive creates on the box for each crossing (section 8.5). receive prints it when it runs, for example 2026-11-01_143502.

Useful keys.

  • Tab finishes a file or folder name you have started typing. Type cd ~/airg and press Tab, and it becomes cd ~/airgap/ if that folder exists. This saves typing and avoids typos.
  • Up arrow brings back the previous command, so you can run it again or fix a mistake in it.
  • Ctrl+C stops a running command.
  • q quits a command that shows text one page at a time, such as less.

Reading the output. Real errors usually contain one of these: error, Error, E:, failed, No such file or directory, Permission denied or command not found. Warnings, often starting with W: or warning:, are usually harmless, and the guide tells you which ones to expect. Section 16 lists common errors and what they mean.

Looking around. You rarely need these, but they help you look before you act:

  • pwd prints the folder you are in.
  • ls lists the files in the current folder; ls ~/airgap lists another folder.
  • cd <folder> moves into a folder. cd on its own goes back to your home folder.

1.3 Editing a file with nano

Some steps ask you to create or change a text file with nano, a simple editor that runs inside the terminal. For example:

sudo nano /etc/modprobe.d/airgap.conf

opens that file, or an empty page if the file does not exist yet. Then:

  • Type, and move around with the arrow keys. The mouse does not move the cursor.
  • Save with Ctrl+O (the letter O). nano shows the file name at the bottom of the screen; press Enter to confirm it.
  • Exit with Ctrl+X. If you try to exit without saving, nano asks whether to save; press Y, then Enter.
  • Ctrl+K deletes the whole line the cursor is on.

The list of shortcuts at the bottom of nano’s screen writes Ctrl as ^, so ^O means Ctrl+O.

When you type a file from this guide, copy every character exactly, including quotes, brackets, # signs and spaces. For lines that start with spaces, type the spaces with the space bar, not the Tab key. Lines starting with # are comments: notes for people, which the computer ignores.

1.4 How to read the rest of this guide

  • Each step says which machine it runs on: (box) or (connected machine). Do not mix them up.
  • A box straight after a nano command is the contents of the file you are editing, not commands to run.
  • On the connected machine, files going to the box are collected in ~/airgap/in/<date>, and files coming from the box are unpacked into ~/airgap/out/<date>.
  • On the box, receive unpacks each crossing into a new folder under ~/in, and send sends whatever you put in ~/out. Section 8.5 creates both.
  • Expected after a command says what a correct result looks like. Where the guide says names will differ, the shape of the output should match even if the details do not.
  • References such as “(5.3)” point to a numbered section of this guide.

2. What this protects against

This section explains what the air gap does and does not do for you. Read it before you build, because it explains why later sections insist on things that look fussy.

2.1 What the air gap stops

With no working network hardware, nothing on the box can send data anywhere, and nothing can reach it over a network. That covers:

  • a program or library on the box that quietly reports home, now or after some future update;
  • attacks over a network on anything running on the box;
  • your prompts, documents and the model’s answers leaving over a network.

2.2 What it does not stop

  • Harmful files you carry in yourself. This is the main risk, and model files are the biggest part of it. A model file is a large, complicated data file, and the part of llama.cpp that reads model files had serious security bugs in 2024, 2025 and 2026: a specially crafted file could make it run an attacker’s code on the box. The rules in section 5 and the checks in sections 9, 11 and 12 exist to keep bad files out.
  • Data you carry out. Anything you put on the OUT drive leaves the box. Section 5 limits it to plain text you have read.
  • Someone with their hands on the box. Disk encryption protects a box that is stolen or seized while switched off: without the passphrase, the disk cannot be read. It does not protect against someone tampering with the box and returning it to you without your noticing.
  • Exotic spying methods that leak data through fan noise, power use or radio emissions. These only work if malware is already on the box. Keeping malware out is the job of the transfer rules.

2.3 Why the transfer rules look the way they do

The gap is only as strong as what crosses it. So most of this guide is about crossings:

  • Software updates are checked against Devuan’s signature. apt on the box refuses any package Devuan did not sign, exactly as it would on a normal computer, whatever happened on the connected machine.
  • llama.cpp and models are checked against their publishers’ checksums, using the phone check, so a file changed on the way is caught.
  • The USB drives carry one sealed bundle, a tar archive, instead of a normal filesystem, and the box never mounts them. The part of Linux that reads filesystems is not built to withstand a deliberately broken one, and a booby-trapped USB stick is a known way to attack a computer. A tar archive is far simpler. The box unpacks it with your ordinary user account, which cannot damage the system, and refuses anything strange inside.

Think of a secure office: visitors never walk in; documents arrive in envelopes through a slot, and every envelope is opened with gloves on, its seal checked, and anything suspicious thrown away.

2.4 The connected machine is not trusted

Assume the connected machine could be hacked without your knowing. The checks on the box are designed so that this does not matter, with one gap: a hacked connected machine could hold back updates by handing over old but genuine files. Sections 9.2 and 11.1 show how to catch that. Two things cannot be checked on the box at all, because they are needed before it is set up: the BIOS update and the Devuan installer. You check those with your everyday computer and your phone (6.1 and 7.1), so a hacked connected machine cannot slip you a bad one.

One small exposure remains: when you plug a drive into the box, Linux reads the drive’s partition table, and the system briefly examines what is on it, before any of your commands run. That is why the drives hold nothing but one partition with one archive.

2.5 Why not just leave the network cable out

A computer on a separate network with no route to the internet gets most of the same privacy with less effort. But one software bug or one configuration mistake can connect it. A physical gap cannot be bridged by software.

3. Hardware

You need the parts below. If a word is unfamiliar: the CPU is the main processor; the motherboard is the board everything plugs into; RAM is the working memory; the PSU (power supply unit) turns wall power into what the parts need; the SSD is the storage drive, here the NVMe kind, which plugs straight into the motherboard.

PartChoiceWhy, and what to watch forApprox. price
GPUUsed NVIDIA RTX 3090, 24 GBThe card that runs the AI. Its 24 GB of VRAM is what lets it hold mid-sized models. Test it before paying (3.1). It needs 2 or 3 PCIe 8-pin power plugs from the PSU, depending on the model; the Founders Edition (NVIDIA’s own design) uses a 12-pin adapter fed by two 8-pin cables.₹80,000 to 1,20,000
CPUAMD Ryzen 5 5600, or any AM4 6-corePlenty for this job, because the GPU does the heavy work. It has no built-in graphics, so the 3090 also drives the monitor. That costs a few hundred MB of VRAM.₹6,500 to 7,500 used
MotherboardB550M without Wi-Fi, for example a Gigabyte B550M DS3H (not the AC version)It must not have Wi-Fi or Bluetooth built in, because those are radios: the model name must not end in WIFI, AX or AC. A used B550 board may need a BIOS update before it accepts a Ryzen 5000 CPU. Ask the seller to confirm it supports the Ryzen 5600, or have the shop update it (3.2).~₹7,500
RAM16 GB DDR4Enough when the model sits entirely in VRAM. Buy 32 GB only if you plan to run models partly on the CPU.₹10,000 to 14,000 (shortage pricing)
PSU750 W, 80 Plus Gold, ATXThe 3090 draws a lot of power in short bursts, and a weak or cheap PSU makes the box switch itself off under load. Use a separate PCIe 8-pin cable for each power socket on the GPU, not one cable with two plugs on it. Buy 1,000 W now if a second 3090 is likely.₹6,000 to 9,500 (1,000 W: ~₹11,000)
SSD1 TB NVMeModels are 15 to 40 GB each.₹8,700 to 12,000
CaseGood airflow, fits a 3-slot card over 30 cm longThe 3090 is huge, and its memory chips run hot. Check the case’s maximum graphics card length against your card’s.~₹4,000 to 6,000
Monitor, keyboard, mouseWired onlyWireless keyboards and mice are radios.-
IN driveUSB 3 flash drive or USB SSD, 128 GB or moreCarries downloads to the box. Big enough for one full-precision 27B model if you use Appendix A.-
OUT driveSmall USB flash drive, 16 to 32 GBCarries requests out. Pick one that looks clearly different from the IN drive, in colour or shape, so you never mix them up.-
Installer stickAny USB stick, 8 GB or moreA third, separate stick, for the BIOS update and the Devuan installer.-

The box without the GPU costs roughly ₹45,000 to ₹60,000.

Plug the monitor into the graphics card, never into the video sockets on the motherboard. The Ryzen 5 5600 has no built-in graphics, so the motherboard’s video sockets do nothing, and a monitor plugged into them stays black.

3.1 Buy and test the used 3090

New 3090s are no longer made, so you will buy a used one. Buy face to face, somewhere you can test the card in a working PC before paying, such as the seller’s home or a shop. As of October 2026 a fair price is around ₹1,00,000, give or take ₹20,000. Listings near ₹60,000 are usually scams. Never pay in advance for a card you have not tested.

Before you go, download two free tools on your everyday computer and copy them onto a USB stick:

  • memtest_vulkan, which checks the card’s memory, the part that most often fails on used cards. Open https://github.com/GpuZelenograd/memtest_vulkan, click Releases on the right, and download the Windows .exe and the Linux file whose name ends in _DesktopLinux_X86_64.tar.xz (not the one marked AARCH64).
  • HWiNFO (Windows only), which reads the card’s temperatures. Get it from its official site, https://www.hwinfo.com.

At the seller’s:

  1. Inspect the card. Look at the gold contacts along the bottom edge, the part that plugs into the motherboard: they should not be bent, scratched through or burnt. Check that no screws are missing, which can mean the card has been opened. Make sure the fans spin when the PC is running.
  2. Run memtest_vulkan on the seller’s PC, with the card installed. On Windows, copy the .exe from your stick to the desktop and double-click it. If Windows shows “Windows protected your PC”, click More info, then Run anyway. On Linux, copy the Linux file into a folder, open a terminal there, unpack it and run the program:
tar -xf memtest_vulkan-*_DesktopLinux_X86_64.tar.xz
chmod +x ./memtest_vulkan
./memtest_vulkan

tar -xf unpacks the archive and prints nothing. chmod +x marks the unpacked file as a program that may be run, and the last line runs it.

Expected: a header with the project address and version, the line To finish testing use Ctrl+C, then a line naming the card, for example:

1: Bus=0x01:00 DevId=0x2204   24GB NVIDIA GeForce RTX 3090

If it names a different card, or does not show 24GB, it is not testing the 3090: ask the seller why. Then progress lines appear. After about five minutes a good card prints:

Standard 5-minute test PASSed! Just press Ctrl+C unless you plan long test run.

The test carries on by itself after that. Press Ctrl+C. It ends with:

memtest_vulkan: no any errors, testing PASSed.
  press any key to continue...

The odd grammar is the tool’s own. Standard 5-minute test fail - ERRORS FOUND, or any line starting with Error found, means the card’s memory is faulty. Do not buy it, whatever the seller says.

  1. Check the memory temperature if the seller’s PC runs Windows. Start HWiNFO while the test runs (choose “Sensors-only” if it asks) and find “GPU Memory Junction Temperature”. Around 105 °C or higher means the card’s thermal pads, the soft pads that carry heat away from the memory chips, need replacing. That is a cheap repair at a computer shop, but it is a reason to negotiate the price down.

3.2 Getting it assembled

If you have never built a computer, have a shop assemble it. Computer shops on SP Road in Bengaluru, and in the computer markets of most cities, do this for a small fee and test the result. Tell them:

  1. Assemble the parts. Connect each power socket on the graphics card with its own separate PCIe cable from the PSU. Check that the PC starts and shows the BIOS setup screen.
  2. Update the motherboard’s BIOS to the newest version if they can. Otherwise you will do it yourself in section 6.
  3. Do not install Windows or any other operating system, and do not connect it to the internet. You will install Devuan yourself.
  4. Do not fit any Wi-Fi card or Bluetooth adapter.

When you get it home, check three things before going further:

  • the monitor cable goes into the graphics card, not the motherboard;
  • the box starts, the fans spin, and pressing Del right after switching on opens the BIOS setup screen (on some boards it is F2);
  • the setup screen shows the full 16 GB of RAM and the SSD.

If you would rather build it yourself, follow the installation chapter of the motherboard’s manual and a step-by-step video for your exact case. The two classic mistakes with this build are feeding the graphics card from one shared power cable, and plugging the monitor into the motherboard.

4. The connected machine

The connected machine is an ordinary Linux computer with internet. It does all the downloading for the box (software updates, llama.cpp and models) and it prepares the USB drives.

It does not need to be trustworthy, and you should not treat it as trustworthy. Everything it hands over gets checked on the box, in ways a hacked connected machine cannot fake. The two exceptions, the BIOS update and the Devuan installer, are checked with your everyday computer and your phone instead (2.4). So an old laptop is perfectly good. Do not use it for anything sensitive, and do not keep anything on it you would mind losing.

4.1 Choose one

  1. An old or cheap used laptop with Devuan installed (recommended). Any laptop from the last ten years with 4 GB of RAM or more will do; a refurbished business laptop such as a ThinkPad costs around ₹10,000 to 15,000. Installing Devuan on it (4.2) is also the best practice you can get for installing it on the box in section 7: the steps are nearly the same, and mistakes on the laptop cost nothing.
  2. A laptop already running Debian. Debian 13 works with every command in this guide.
  3. A disposable qube on Qubes OS (advanced). Only if you already use Qubes. See 4.5.

Your everyday Windows or Mac computer will not do: the commands in this guide need Linux.

4.2 Install Devuan on the laptop

You will download the Devuan installer on your everyday computer, put it on the installer stick, and install Devuan on the laptop. This erases everything on the laptop.

  1. Download. On your everyday computer, open https://files.devuan.org/, go into the folder for the current stable release (devuan_excalibur/ at the time of writing), then into installer-iso/. Download the file whose name ends in _amd64_desktop.iso (several GB) and the checksum list, SHA256SUMS.txt (older releases call it SHA256SUMS).

  2. Check the download. Open SHA256SUMS.txt in a text editor and find the line for your .iso file. Then work out the checksum of the file you downloaded:

    • Windows: open PowerShell from the Start menu, type cd Downloads and press Enter, then run Get-FileHash <file>.iso -Algorithm SHA256 with the real file name. Windows prints the checksum in capital letters and the list uses small letters; that difference does not matter.
    • Mac: open Terminal and run shasum -a 256 ~/Downloads/<file>.iso with the real file name.
    • Linux: run sha256sum ~/Downloads/<file>.iso.

    The two checksums must match in every character. If they do not, delete the file and download it again. A tip for comparing long codes: compare them in groups of eight characters, start to finish. The stronger check with Devuan’s signature comes later, in section 7.1, for the box’s own installer. For the connected machine, this is enough.

  3. Write the installer stick. This erases the stick. On Windows, use Rufus (https://rufus.ie): pick the stick under Device, click Select and choose the .iso, then click Start. If Rufus asks whether to write in ISO image mode or DD image mode, choose DD image mode, even though ISO mode is marked Recommended. If Windows then offers to format the stick, click Cancel. On a Mac (or Windows) you can use balenaEtcher instead (https://etcher.balena.io): Flash from file, Select target, Flash. Wait until it says it has finished, then unplug the stick.

  4. Start the laptop from the stick. Plug the stick into the laptop. Switch it on and immediately press the boot menu key repeatedly. It is usually F12, F9, F11, Esc or Enter; the laptop’s start-up screen or manual says which. Pick the USB stick in the menu. If the laptop refuses with a message about security or Secure Boot, open its firmware settings, disable Secure Boot, and try again.

  5. Install. Follow the steps in section 7.3, with these differences, because this machine is allowed online:

    • At the network step, connect to your Wi-Fi or plug in a network cable, and let the installer set up the network. If it finds no Wi-Fi, use a cable.
    • When asked whether to use a network mirror, answer Yes, choose India or a nearby country, and accept the suggested mirror.
    • Encryption is optional on this machine. It is still a good idea.
    • Keep the Xfce desktop. Leave the SSH server unticked.
    • At the partitioning and boot loader steps, pick the laptop’s own disk, never the USB stick you are installing from. On many laptops the disk is called sda rather than nvme0n1; its size matches the laptop’s disk.
  6. Log in and update. Open a terminal (1.2) and run:

sudo apt update
sudo apt upgrade

apt update downloads the current list of available software. apt upgrade installs newer versions of what you already have.

Expected: lines starting with Get: or Hit:, then Reading package lists... Done. upgrade may list packages and ask Do you want to continue? [Y/n]; press Enter to accept. There must be no line starting with E:; if there is one mentioning a cdrom, see section 16. Run these two commands every week or so to keep the laptop up to date.

4.3 Install the tools

sudo apt install curl gnupg git parted dosfstools pipx

This installs curl, which downloads files from the command line; gnupg, which checks signatures; git, for llama.cpp’s source code; parted and dosfstools, for preparing USB drives; and pipx, for the download tool in 4.4.

Expected: apt lists what it will install and asks Do you want to continue? [Y/n]; press Enter. It downloads and installs the packages and returns to the prompt with no line starting with E:.

4.4 Optional: the Hugging Face download tool

Models come from a website called Hugging Face (section 12). You can download them in the browser, or with a command-line tool called hf, which can resume a broken download and is handier for big files. To install it:

pipx install huggingface_hub

Expected: pipx reports the installed package and lists hf among the commands now available. If it warns that ~/.local/bin is not on your PATH, run pipx ensurepath, then close the terminal and open a new one.

4.5 If your connected machine is a Qubes qube

Use a disposable qube based on a Debian 13 template. Run the apt commands in that template, and the pipx command in the disposable template itself (usually default-dvm), so every new disposable has the tools. Attach the IN or OUT drive to the disposable from the Qubes devices widget. Attach the whole drive (for example sys-usb:sdb), not one of its partitions.

5. Transfer drives and rules

5.1 Rules

These rules are the heart of the air gap. Each one closes a specific hole, so each comes with its reason. The short version: each drive has exactly one writer, and neither drive is ever mounted.

  1. The IN drive is written only by the connected machine. The box only reads it, with receive, which makes the drive read-only first. Why: if the box could write to the IN drive, malware on the box could use it to smuggle data out.
  2. The OUT drive is written only by the box, with send. The connected machine only reads it. Why: the OUT drive is plugged into the box on every trip, so anything the connected machine wrote onto it would ride back in.
  3. Neither drive carries a filesystem. Each holds one tar archive, replaced on every crossing. Why: a filesystem is complicated to read and a known way to attack a computer (2.3); a tar archive is simple.
  4. Never plug either drive into any other computer, and plug in only the drive the step needs. Why: every extra computer is one more chance for something to get onto the drive.
  5. Only these cross on the IN drive: Devuan package files fetched as section 9 describes, a llama.cpp release with the web interface archive that belongs to it, GGUF model files, and plain text files (.txt) that you wrote yourself, or saved from a web page or document and read through before writing the drive. Why: packages are checked by apt, llama.cpp and models by a phone check, and plain text is something you have read yourself. Nothing else can be checked.
  6. Never carry in programs from anywhere else, Python or JavaScript packages, or model files in “pickle” formats (file names ending in .bin, .pt, .pth or .ckpt). Why: these can run code the moment they are opened or loaded, and none of them can be checked the way packages are.
  7. Only these cross on the OUT drive: package requests, which are plain text, and plain text you have already read on screen. Why: anything else could carry your data out without your knowing.
  8. Check everything on the box before you use it: packages through apt, everything else with a phone check. Log every crossing (Appendix B). Why: the checks are the security, and the log lets you see later what went in and out.
  9. Never plug a phone into the box, not even to charge it. Why: a phone is a computer with radios and its own internet connection.
  10. Never connect a network cable to the box, not even for five minutes. Why: five minutes is enough for anything hiding on the box to send out everything on it.

5.2 Prepare both drives (connected machine)

Do this once for each drive. It erases the drive completely and turns it into a transfer drive: one partition, given a name (a label) so both machines can recognise it, with no filesystem.

Warning: choosing the wrong drive here erases it, and there is no undo. If the wrong drive is the laptop’s own disk, you erase the laptop. Unplug every other USB drive first, and check the size and model before every command that writes.

  1. Plug in the drive, then list all drives:
lsblk -o NAME,SIZE,MODEL,TRAN,MOUNTPOINTS

lsblk lists Linux’s block devices, which means drives and partitions. The rest of the line picks which columns to show.

Expected: one line per drive and per partition. Yours will differ, but it looks something like this:

NAME          SIZE MODEL              TRAN   MOUNTPOINTS
sda         238.5G Samsung SSD 860    sata
├─sda1        512M                           /boot/efi
└─sda2        238G                           /
sdb         117.2G SanDisk 3.2Gen1    usb
└─sdb1      117.2G                           /media/john/SANDISK

Here sda is the laptop’s own disk: it says sata and has the system mounted on /. sdb is the USB drive: it says usb and its size matches the drive. The name you need is the drive’s, without a number: sdb, not sdb1. A laptop with an NVMe disk shows its own disk as nvme0n1; never pick that one.

If the USB drive’s line, or a line under it, shows a mount point such as /media/john/SANDISK, the laptop has mounted it. Unmount it, typing the mount point shown on your screen:

sudo umount /media/john/SANDISK

Expected: no output. Run the lsblk command again: the drive’s MOUNTPOINTS column should now be empty. If it had several partitions, unmount each one that shows a mount point.

In a Qubes qube, the attached drive appears as xvdi, with an empty TRAN column.

  1. Wipe the old signatures, then create one partition named AIRGAP-IN, with no filesystem. Replace <dev> with the name you noted, for example sdb:
sudo wipefs -a /dev/<dev>
sudo parted --script /dev/<dev> mklabel gpt mkpart AIRGAP-IN 1MiB 100%
sudo wipefs -a /dev/<dev>1

What these do: wipefs erases the markers that tell computers what a drive holds, so nothing recognises the old contents. parted writes a fresh partition table (gpt is the modern type) with one partition named AIRGAP-IN covering the whole drive. The second wipefs makes sure the new partition carries no leftover filesystem marker.

For the OUT drive, use AIRGAP-OUT instead of AIRGAP-IN.

Expected: each wipefs prints one line per marker it erased, such as /dev/sdb: 8 bytes were erased at offset 0x00000200 (gpt): 45 46 49 20 50 41 52 54, or nothing if there was none. The first may also print calling ioctl to re-read partition table: Success. parted prints nothing, or a line saying you may need to update /etc/fstab, which you can ignore. If wipefs says the device is busy, something on it is still mounted: go back to step 1.

The laptop’s desktop may pop up a message about the drive after this. Close it, and do not click on the drive in the file manager.

  1. Check the result:
lsblk -o NAME,SIZE,FSTYPE,PARTLABEL /dev/<dev>
ls -l /dev/disk/by-partlabel/

Expected: the partition has an empty FSTYPE column, meaning no filesystem, and AIRGAP-IN (or AIRGAP-OUT) in the PARTLABEL column. ls shows a link with the same name pointing at the partition, for example AIRGAP-IN -> ../../sdb1. That link is how both machines find the drive from now on, whatever name Linux happens to give it.

  1. Unplug the drive. Write IN on one drive and OUT on the other with a permanent marker.

5.3 Crossings on the connected machine

Every crossing works the same way. The rest of the guide refers to these two procedures as “write the IN drive (5.3)” and “read the OUT drive (5.3)”.

Writing the IN drive. Put everything for the crossing in the folder ~/airgap/in/<date>; later sections say what goes there. Give files plain names, using only English letters, digits and ordinary keyboard punctuation: receive refuses names with accented letters or special symbols (8.5), so rename such files first. Plug in only the IN drive, wait a few seconds, and run:

tar -cf - -C ~/airgap/in/<date> . | sudo dd of=/dev/disk/by-partlabel/AIRGAP-IN bs=4M conv=fsync,nocreat status=progress

What it does: tar packs every file in the folder into one archive and passes it through the | sign (a pipe, which hands one command’s output to the next). dd writes the archive straight onto the IN drive’s partition, found by its label. conv=fsync makes sure every byte is on the drive before the command finishes, and nocreat makes dd stop with an error if the drive is not plugged in, instead of writing a file by mistake.

Expected: a progress line that updates while it writes, then a short summary whose last line looks like:

<bytes> bytes (<size> MB, <size> MiB) copied, <seconds> s, <speed> MB/s

No such file or directory means the IN drive is not plugged in, or in Qubes not attached. In a Qubes qube, if that path is missing, use /dev/xvdi1, which is the same partition. When the prompt comes back, the data is on the drive. Unplug it (in Qubes, detach it from the qube first).

Reading the OUT drive. Plug in only the OUT drive and run:

mkdir -p ~/airgap/out/<date>
sudo cat /dev/disk/by-partlabel/AIRGAP-OUT | tar -xvf - -C ~/airgap/out/<date>

mkdir -p creates the folder. The second line reads the archive off the drive and unpacks it into that folder.

Expected: ./ followed by the name of each file as tar unpacks it. Unplug the drive.

The box’s side of each crossing uses two small programs, receive and send, which you create in section 8.5.

6. Firmware settings

The firmware is the motherboard’s built-in setup program (1.1). In this section you update it, switch off every piece of network hardware on the board, and set a few security options. Do it with the box fully assembled, before installing anything.

Opening the setup screen. Switch the box on and immediately press Del repeatedly (on some boards F2) until the setup screen appears. If the box shows something else, such as a message that no bootable device was found, switch it off and try again, pressing the key sooner. Many boards open in a simplified “easy mode” first. Switch to the advanced mode, which has all the settings below: on Gigabyte boards press F2, on ASUS and MSI boards F7; the key is usually shown on screen.

Move with the arrow keys and Enter, and go back with Esc. Nothing changes until you save, usually with F10.

Menu names vary by vendor and BIOS version. The examples below are for Gigabyte boards such as the B550M DS3H. On other brands, look for the same words.

6.1 Update the firmware

Update first, while the box is still empty. Updates fix bugs, including security fixes for the CPU, which on AMD desktop boards arrive mainly through BIOS updates. If the shop already updated it to the newest version, skip to 6.2.

  1. Find your exact board model and revision. Both are printed on the motherboard itself, usually near the graphics card slot, for example B550M DS3H and REV: 1.3. The setup screen’s main page also shows the model and the current BIOS version.

Steps 2 to 4 use your everyday computer, not the connected machine. The box has no way to check a BIOS file, so keep the untrusted machine out of it. Letting the shop do the update (3.2) avoids these steps altogether.

  1. Download the BIOS file (everyday computer). Open the board vendor’s support page for your exact model and revision. For Gigabyte, search for the model on gigabyte.com, open Support, then BIOS. Download the newest version. It comes as a .zip file. If the page publishes a checksum for it, compare it with your file’s checksum as in 4.2 step 2. Then extract the zip: on Windows, right-click it and choose Extract All; on a Mac, double-click it.
  2. Format the installer stick (everyday computer). Use a stick of 32 GB or smaller. This erases it.
    • Windows: plug the stick in, open File Explorer, right-click the stick, choose Format, set File system to FAT32, and click Start. If FAT32 is not offered, the stick is larger than 32 GB: use a smaller one.
    • Mac: open Disk Utility, choose View, then Show All Devices, and select the stick itself (the top line for it). Click Erase, choose the format MS-DOS (FAT) and the scheme Master Boot Record, and click Erase.
    • Linux: find the stick’s device name with lsblk as in 5.2, unmount it if it shows a mount point, and run the commands below.

Warning: this erases the stick. Check <dev> before you run it.

sudo wipefs -a /dev/<dev>
sudo parted --script /dev/<dev> mklabel msdos mkpart primary fat32 1MiB 100%
sudo mkfs.vfat -F 32 -n BIOS /dev/<dev>1

wipefs erases the markers of whatever was on the stick before. parted makes one partition. mkfs.vfat puts a FAT32 filesystem on it, named BIOS, which every motherboard can read.

Expected: wipefs prints the markers it erased, as in 5.2. parted prints nothing. mkfs.vfat prints one line naming mkfs.fat and its version.

  1. Copy the BIOS file onto the stick (everyday computer). On Linux, unplug the stick and plug it back in first, so the file manager shows it. Copy the BIOS file itself onto the stick. For Gigabyte it is the file named after the board with an ending such as .F18; the other files in the zip are not needed. Then eject the stick before unplugging it: on Windows, right-click it in File Explorer and choose Eject; on a Mac, click the eject symbol next to it in Finder; on Linux, right-click it in the file manager and choose Eject or Unmount.
  2. Flash it (box). Plug the stick into the box, open the setup screen, and start the board’s update tool: Q-Flash on Gigabyte (press F8, or click Q-Flash), EZ Flash on ASUS, M-Flash on MSI. Choose to update the BIOS from a drive, pick the file on the stick, and confirm. Do not switch off or reset the box while it flashes. It can take several minutes and may restart by itself more than once. When it is back at the setup screen, the main page shows the new version.

A BIOS update puts every setting back to its default, so make the changes below after updating, not before.

6.2 Switch off the network hardware

  1. Disable the onboard network (LAN) controller. On Gigabyte: Settings, then IO Ports, then Onboard LAN Controller, set to Disabled. On other boards, look under Advanced, Onboard Devices or Peripherals.
  2. Wi-Fi and Bluetooth. The recommended board has neither. If yours turns out to have them, disable both here, or better, have a shop remove the small Wi-Fi card from its slot.

6.3 Boot and security settings

  1. Disable CSM (Compatibility Support Module, also called legacy boot), so the board only starts systems the modern UEFI way. On Gigabyte: Boot, then CSM Support, set to Disabled. Then save with F10 and open the setup screen again: on Gigabyte boards, the Secure Boot setting can only be changed once CSM is off.
  2. Disable Secure Boot. On Gigabyte: Boot, then Secure Boot; set it to Disabled if it is not already. Why: with Secure Boot on, Linux refuses to load the NVIDIA driver that the box builds for itself in section 10 unless you go through an extra key-signing procedure. And on Devuan, Secure Boot would not cover the whole start-up anyway, because Debian-family systems do not sign the initramfs, a small start-up bundle you will meet in section 8.
  3. Boot order. Put the internal NVMe drive first. Before Devuan is installed it may not be listed yet; that is fine. You will start the installer stick once, from the one-time boot menu, which most boards open with F12 (Gigabyte) or F11 right after switching on.
  4. Set an administrator password. On Gigabyte: Boot, then Administrator Password; elsewhere it may be called Supervisor Password. With it set, nobody can change these settings without the password. On Gigabyte, Boot, then Security Option decides when the password is asked: Setup asks only when someone opens the setup screen; System asks at every start, which also stops anyone starting the box from another drive without it. System is the safer choice; it means typing this password at every start, before the encryption passphrase. Write the password down and keep it safe: if you forget it, resetting it means opening the box.
  5. Save and exit with F10, and confirm.

Expected: the box restarts. Open the setup screen again (it now asks for the administrator password) and check that the LAN controller shows as Disabled.

7. Install Devuan

Now you install Devuan on the box, with the whole disk encrypted and no network.

7.1 Download and verify the installer (connected machine)

This check matters more than any other in the guide. The installer brings the keys that apt uses to check every package the box will ever install, so a tampered installer could fool every later check. Steps 1 to 4 run on the connected machine: a checksum (is this exactly the published file?) and a signature (did a Devuan developer publish it?), with the signer confirmed on your phone. Step 5 then checks the image again on your everyday computer, so that a hacked connected machine cannot fool you by faking what it shows.

  1. Download four files. On the connected machine, open https://files.devuan.org/, go into the folder for the current stable release (devuan_excalibur/ at the time of writing), then into installer-iso/. Download:

    • the amd64 desktop image, the file whose name ends in _amd64_desktop.iso. It is the full image that installs without a network. You can reuse the one from 4.2 if you still have it;
    • the checksum list, named SHA256SUMS.txt at the time of writing (older releases call it SHA256SUMS);
    • its signature, the same name with .asc added.

    Also download devuan-devs.gpg from the top level of https://files.devuan.org/. It holds the Devuan developers’ public keys.

    Put all four files in one folder. Open a terminal in that folder: in the file manager, right-click an empty area inside the folder and choose Open Terminal Here. Run ls to see the four files. The commands below use the current file names; adjust them if yours differ.

  2. Check the image against the checksum list.

sha256sum --ignore-missing -c SHA256SUMS.txt

This works out the checksum of each file in the list that you have, and compares it with the list. --ignore-missing skips the many images in the list that you did not download.

Expected:

devuan_excalibur_<version>_amd64_desktop.iso: OK

FAILED means the download is damaged or altered: delete it and download it again.

  1. Check the signature on the checksum list. This proves that the list was signed with a Devuan developer’s key. Name both files: the signature is a separate file, so gpg needs the list it signs.
gpg --no-default-keyring --keyring ./devuan-devs.gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt

Expected: lines like these, in this order (names, dates and numbers will differ):

gpg: Signature made <date and time>
gpg:                using RSA key <40 letters and digits>
gpg: Good signature from "<a Devuan developer's name and email>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: <ten groups of four characters>

The words that matter are Good signature. The warning is normal here: gpg only means that you have not personally vouched for this key, which is what the next step does instead. BAD signature means stop and do not use these files. If gpg says no signed data, you left out the second file name. The first time you use gpg it may also print a line saying it created a folder or a keybox, and there may be lines starting issuer or Subkey fingerprint:. These are normal; the next step uses the line starting Primary key fingerprint:.

  1. Confirm who signed it, on your phone. The keyring file came from the same website as the image, so a fake website could have supplied a fake key too. Devuan’s team page is the independent reference. On your phone, open https://devuan.org/os/team. It lists each developer with a key ID: 0x followed by 16 characters.

    Now take the Primary key fingerprint line from step 3. It has ten groups of four characters. The key ID is the last four groups, joined together without spaces. For example, if the line were

Primary key fingerprint: 4F21 9A0C 77D3 E58B 2B7A  1C3E 8D44 F0E9 5A21 9C6B

the last four groups are 8D44 F0E9 5A21 9C6B, so the key ID is 8D44F0E95A219C6B. (These numbers are made up; yours will differ.) Find your key ID on the team page, after a 0x. It must match one listed developer exactly, all 16 characters. If it does not, stop and do not install.

  1. Cross-check the image on your everyday computer and your phone. Steps 2 to 4 ran on the connected machine, and a hacked one could fake what they printed. So check the image a second way, without it:

    • On your everyday computer, use the .iso you downloaded there in 4.2. If a newer Devuan release has come out since, download the new one there first. Work out its checksum as in 4.2 step 2.
    • On your phone, open https://files.devuan.org/, go into the same release folder, then installer-iso/, and open SHA256SUMS.txt. Find the line for the same .iso file name.
    • The two checksums must match in every character. If they do not, stop.

    You will write the box’s installer stick from this same file, on the everyday computer (7.2), so the connected machine never touches what the box installs.

7.2 Write the installer stick (everyday computer)

Write the .iso you checked in 7.1 step 5 onto the installer stick, on your everyday computer, exactly as in 4.2 step 3: Rufus in DD image mode on Windows, or balenaEtcher, which also checks the stick after writing it. This erases the stick, including the BIOS file on it.

If your everyday computer runs Linux, you can use dd instead. Open a terminal in the folder that holds the .iso (press Tab to complete its long name), find the stick’s device name with lsblk as in 5.2, and unmount it if it shows a mount point.

Warning: this erases the stick. Check <dev> before you run it.

sudo dd if=<image>.iso of=/dev/<dev> bs=4M status=progress conv=fsync

if= is the input file, the installer image. of= is the output: the whole stick, so use the drive’s name without a number (sdb, not sdb1).

Expected: a progress line that updates as it writes, then a short summary whose last line looks like:

<bytes> bytes (<size> GB, <size> GiB) copied, <seconds> s, <speed> MB/s

When it has finished, unplug the stick.

7.3 Install

The installer asks a series of questions, one screen at a time. Answer as below. Continue moves on and Go Back undoes a step. If a screen appears that is not listed here, its default answer is fine.

  1. Plug the stick into the box, switch on, and press the one-time boot menu key (F12 on Gigabyte) repeatedly. If the box asks for a password first, type the administrator password from 6.3. Pick the entry for the stick that starts with UEFI:, for example UEFI: SanDisk.
  2. Choose Graphical install.
  3. Choose your language, location and keyboard layout. Choosing India as the location sets the clock to Indian time.
  4. Network: the installer says it found no network interfaces, because you disabled the LAN. This is expected. Continue. If it does find one, choose “Do not configure the network at this time”.
  5. Hostname: the computer’s name. Anything works, for example airgap. Leave the domain name empty.
  6. Root password: leave both fields empty and continue. The installer then lets your own user account use sudo (1.1) instead of having a separate root password.
  7. User account: enter your full name, a short user name in lower case (for example john), and a password. You will use this password to log in and for sudo, so choose one you can type reliably.
  8. Partitioning, which divides up and encrypts the disk:
    • Choose “Guided - use entire disk and set up encrypted LVM”. Encrypted LVM means the whole disk is encrypted, with the system’s partitions inside the encrypted part.
    • Pick the NVMe drive. It has nvme in its name and the size of your SSD.
    • Choose “All files in one partition”.
    • Confirm writing the changes and configuring LVM with Yes.
    • The installer then starts overwriting the disk with random data. On a new SSD you can press Cancel: it takes hours and adds little on an SSD.
    • Enter the encryption passphrase, twice. Use six or more random words, for example picked by opening a dictionary at random. You will type it every time the box starts. If you lose it, everything on the box is gone for good; nobody can recover it. Write it down and keep it somewhere safe, away from the box.
    • Keep the suggested amount of the disk to use, which is all of it.
    • On the summary screen, choose to finish partitioning and write the changes to disk, then confirm with Yes.
  9. If asked to scan more installation media, answer No.
  10. When asked whether to use a network mirror, answer No. A warning that the installation may be incomplete is expected: everything the box needs to start is on the stick, and the rest arrives later through section 9.
  11. Software selection: keep the Devuan desktop environment, Xfce and the standard system utilities. Untick SSH server, which would let other computers log in over a network, and print server if it is listed.
  12. If asked to choose an init system, the program that starts everything else, keep sysvinit.
  13. Boot loader: install the GRUB boot loader, the program that starts Devuan, on the NVMe drive. If asked whether to force GRUB installation to the EFI removable media path, answer Yes. That puts a second copy of the boot loader where every motherboard looks by default, so the box still starts if a later BIOS update forgets Devuan’s boot entry.
  14. When told, remove the stick and let the box restart.

Expected: before the desktop starts, a text prompt asks for the passphrase, for example:

Please unlock disk nvme0n1p3_crypt:

Type the encryption passphrase and press Enter. As with sudo, nothing appears while you type. Then the graphical login screen appears. Log in with your user name and password.

7.4 Check the encryption

Open a terminal (1.2) and run:

lsblk -o NAME,FSTYPE,FSVER,MOUNTPOINTS

Expected: the largest NVMe partition shows crypto_LUKS (LUKS is Linux’s disk encryption), and the part mounted on /, which is the system itself, sits inside it. Names will differ, but the shape looks like this:

nvme0n1
├─nvme0n1p1                vfat         FAT32    /boot/efi
├─nvme0n1p2                ext4         1.0      /boot
└─nvme0n1p3                crypto_LUKS  2
  └─nvme0n1p3_crypt        LVM2_member  LVM2 001
    ├─airgap--vg-root      ext4         1.0      /
    └─airgap--vg-swap_1    swap         1        [SWAP]

The indentation shows what is inside what: the encrypted partition holds the LVM area, which holds root, mounted on /, and swap. The 2 after crypto_LUKS is the LUKS version. If / is not under a crypto_LUKS entry, the disk is not encrypted: install again.

8. Cut the network for good

You disabled the network hardware in the firmware. This section makes sure that, even if a network device were somehow switched back on or plugged in, Linux could not use it. Then it sets up the two small programs that handle every crossing.

From here on, everything happens on the box unless a step says otherwise.

8.1 See what network hardware is visible

lspci -k | grep -i -A3 -E 'ethernet|network'
lsusb

lspci -k lists the hardware on the motherboard and the driver each piece uses; grep keeps only network devices and the three lines after each. lsusb lists the USB devices.

Expected: with the LAN disabled in the firmware, the first command prints nothing. If it prints a controller, note the name after Kernel driver in use:, for example r8169; you need it in 8.2. lsusb should list your keyboard, your mouse and lines mentioning “root hub”. It may also list the motherboard’s lighting controller, often named ITE or Integrated Technology Express. Anything mentioning Bluetooth, Wireless or WLAN is not normal: go back to 6.2. If lsusb says command not found, run grep -H . /sys/bus/usb/devices/*/product instead, which lists the same devices by name.

8.2 Block network and Bluetooth drivers

This tells Linux never to load the drivers for network and Bluetooth hardware, so such hardware could not work even if it were present.

sudo nano /etc/modprobe.d/airgap.conf

Type in the following. If 8.1 showed a driver that is not in this list, add a blacklist line and an install line for it too, in the same pattern.

# A "blacklist" line stops a driver loading automatically.
# An "install <name> /bin/false" line makes every attempt to load it fail.

# Onboard Ethernet
blacklist r8169
install r8169 /bin/false
blacklist igc
install igc /bin/false
blacklist e1000e
install e1000e /bin/false

# USB Ethernet adapters and phone tethering
install usbnet /bin/false
install r8152 /bin/false
install lan78xx /bin/false
install ipheth /bin/false

# Every Wi-Fi driver needs cfg80211
install cfg80211 /bin/false

# Bluetooth
install bluetooth /bin/false

The <name> in the second comment line is part of the comment; type it as shown.

Save with Ctrl+O and Enter, then exit with Ctrl+X.

Linux loads a small start-up bundle called the initramfs at the very beginning, before it unlocks the disk. Rebuild it so the rules apply there too:

sudo update-initramfs -u

Expected:

update-initramfs: Generating /boot/initrd.img-<kernel version>-amd64

Restart the box: click Applications, then Log Out, then Restart. Or run sudo reboot. Every restart asks for the encryption passphrase.

8.3 Remove network services

Some programs installed by default exist only to manage network connections, or to offer services over a network. Remove them.

dpkg -l | grep -E 'network-manager|connman|wpasupplicant|bluez|avahi-daemon|openssh-server|cups-daemon'

dpkg -l lists every installed package, and grep keeps the ones named. In order, these manage network connections (network-manager, connman), log in to Wi-Fi (wpasupplicant), run Bluetooth (bluez), announce the computer on a network (avahi-daemon), allow remote logins (openssh-server) and run the printing service, which also listens on the network (cups-daemon).

Expected: one line starting with ii (meaning installed) for each of these that is installed, for example:

ii  network-manager    <version>    amd64    network management framework (daemon and userspace tools)

Remove the ones it lists, by name, separated by spaces:

sudo apt-get purge <package names from the list>

Expected: apt-get lists the packages it will remove under The following packages will be REMOVED: and asks Do you want to continue? [Y/n]. Read the list before answering. It may include extra packages that depend on the ones you named. Packages whose names start with task- (bundles that pulled in the desktop) and tray icons such as network-manager-gnome can go. If the list includes xfce4 or xorg, answer n: that would remove the desktop. Then purge the packages one at a time instead, and skip the one whose removal list includes xfce4 or xorg. Otherwise answer y. It finishes after lines starting with Removing and Purging configuration files.

From now on, apt may suggest running apt autoremove. Never run it on this box: with the task- packages gone, it would remove most of the desktop.

8.4 Turn off automounting

The transfer drives hold no filesystem. But the connected machine writes them, and a hacked one could add a partition with a filesystem under any name. So the desktop must never mount anything by itself, and USB drives should not even appear in the file manager. The box never needs to mount a USB drive.

  1. Click Applications, then Settings, then Removable Drives and Media. On the Storage tab, untick “Mount removable drives when hot-plugged” and “Mount removable media when inserted”. Close the window.

  2. Hide all USB drives from the desktop. udev is the part of Linux that reacts when you plug in hardware; this rule tells it to mark every USB drive as one the desktop must ignore.

sudo nano /etc/udev/rules.d/90-airgap.rules

Type:

# Hide every USB drive from the desktop, so nothing on one can be mounted with a click.
SUBSYSTEM=="block", ENV{ID_BUS}=="usb", ENV{UDISKS_IGNORE}="1"

Note the double == in the first two parts and the single = in the last; they mean different things. Save and exit, then load the rule:

sudo udevadm control --reload

Expected: no output.

  1. Check by plugging in the IN drive:
lsblk -o NAME,FSTYPE,PARTLABEL,MOUNTPOINTS

Expected: the drive’s partition shows AIRGAP-IN in the PARTLABEL column and nothing in the FSTYPE and MOUNTPOINTS columns. No file manager window opens, and the drive does not appear in the file manager’s side panel. Unplug the drive.

8.5 Transfer helpers

Two short programs, called scripts, handle the box’s side of every crossing:

  • receive makes the IN drive read-only, so nothing on the box can write to it, then unpacks its archive into a new folder under ~/in, using your ordinary account rather than root. It keeps nothing at all if tar reports any problem, or if the archive holds anything other than plain files and folders with plain names: no links, no device files, no names that point outside the folder, and no names with anything other than plain English letters, digits and keyboard punctuation. It gives the unpacked files the box’s own date and time, so the two machines’ clocks do not need to agree.
  • send packs whatever is in ~/out into one archive, writes it to the OUT drive, and then moves the files to a dated folder under ~/sent, so you always have a record of exactly what left the box.

You create them by typing them into nano. This is the longest typing job in the guide: allow 20 minutes, and type carefully. The scripts go in /usr/local/bin, the folder for programs you add yourself; anything there can be run by name from any folder.

Typing tips:

  • Copy every character exactly. Pay special attention to the difference between ( and {, between ' and ", and to spaces around [ and ].
  • Indented lines start with four spaces. Type them with the space bar, not the Tab key.
  • The line ending in a single \ continues on the next line. Type the \, press Enter, then type the four spaces and the rest.
  • Do not leave empty lines at the end.

Create the first script:

sudo nano /usr/local/bin/receive

Type this, then save and exit:

#!/bin/sh
# Unpack the archive on the IN drive into a new folder under ~/in.
# The drive is made read-only first and is never mounted.
set -eu
link=/dev/disk/by-partlabel/AIRGAP-IN
[ -e "$link" ] || { echo "IN drive not found" >&2; exit 1; }
part=$(readlink -f "$link")
disk=/dev/$(lsblk -no PKNAME "$part")
sudo blockdev --setro "$disk"
sudo blockdev --setro "$part"
mkdir -p "$HOME/in"
dest="$HOME/in/$(date +%Y-%m-%d_%H%M%S)"
mkdir "$dest"
log="$dest.log"
reject() {
    chmod -R u+rwX "$dest" 2>/dev/null || true
    rm -rf "$dest"
    echo "Nothing was kept: $1" >&2
    cat -v "$log" >&2
    rm -f "$log"
    exit 1
}
sudo cat "$part" | tar -x -m -f - -C "$dest" 2>"$log" || reject "tar could not unpack the archive."
if [ -s "$log" ]; then
    reject "tar printed warnings, which a clean archive never causes."
fi
chmod -R u+rwX "$dest"
LC_ALL=C find "$dest" ! -type d \( ! -type f -o -links +1 \) -o -name '*[![:print:]]*' >"$log" 2>&1 \
    || echo "find failed" >>"$log"
if [ -s "$log" ]; then
    reject "the archive holds links, special files or unprintable names:"
fi
rm -f "$log"
echo "Received into $dest:"
cd "$dest"
find . -type f | sed 's|^\./||' | sort

Check your typing in two ways. First, ask the shell to read the script for mistakes without running it:

sh -n /usr/local/bin/receive

Expected: no output. If it prints an error, it names a line number. Open the file again with sudo nano /usr/local/bin/receive, find that line (press Ctrl+C in nano to see which line the cursor is on), and compare it, and the lines just before it, with the guide.

Then compare the file’s checksum with the one below. It matches only if every character is exactly as printed here, including the four spaces at the start of indented lines:

sha256sum /usr/local/bin/receive

Expected:

1f583779c2dda69df988e93e73b6cac39733418650781a0e096c982bb520282a  /usr/local/bin/receive

If the checksum differs, at least one character differs. Usual causes: a missing or extra quote mark, ( instead of {, a missing space, a Tab instead of four spaces, or an empty line at the end. Compare line by line until it matches. A script that passes sh -n can still contain a mistake that changes what it does, so do not settle for a different checksum.

Create the second script:

sudo nano /usr/local/bin/send

Type this, then save and exit:

#!/bin/sh
# Write everything in ~/out to the OUT drive as one tar archive,
# then move it to ~/sent as a record of what left the box.
set -eu
link=/dev/disk/by-partlabel/AIRGAP-OUT
[ -e "$link" ] || { echo "OUT drive not found" >&2; exit 1; }
part=$(readlink -f "$link")
cd "$HOME/out"
[ -n "$(ls -A)" ] || { echo "Nothing to send: ~/out is empty" >&2; exit 1; }
archive=$(mktemp "$HOME/.send-XXXXXX")
trap 'rm -f "$archive"' EXIT
tar -c -f "$archive" .
sudo dd if="$archive" of="$part" bs=4M conv=fsync,nocreat status=none
record="$HOME/sent/$(date +%Y-%m-%d_%H%M%S)"
mkdir -p "$record"
find . -mindepth 1 -maxdepth 1 -exec mv -t "$record" {} +
echo "Sent. Moved to $record:"
ls -l "$record"

Check it the same way:

sh -n /usr/local/bin/send
sha256sum /usr/local/bin/send

Expected: sh -n prints nothing, and sha256sum prints:

200454ec08fc6e7cc22ed7dcf5fb8d7b0d62dc7c4fb6c849e42a4a0aa2af1264  /usr/local/bin/send

Make both scripts runnable, and create the folder for outgoing files:

sudo chmod 755 /usr/local/bin/receive /usr/local/bin/send
mkdir -p ~/out

chmod 755 lets every user run the scripts while only root can change them.

Expected: no output.

How to use them:

  • Run receive and send as yourself, without sudo in front. They ask for your password when they need root to read or write the drive itself.
  • Unplug each drive as soon as receive or send has finished.
  • If receive prints Nothing was kept, the archive failed a check, and the lines after it show what failed. If they show a file name containing M- sequences, that name has accented letters or special symbols: rename the file on the connected machine and write the IN drive again. Otherwise just write the IN drive again. If it happens twice with plain names, stop trusting the connected machine.
  • Text that came off the IN drive can contain hidden control characters that change what the terminal displays. Read such files with cat -v or less, never with plain cat.

Test both with a first crossing. On the connected machine:

mkdir -p ~/airgap/in/<date>
echo "first crossing" > ~/airgap/in/<date>/hello.txt

echo prints the text, and > sends it into a new file instead of the screen.

Expected: no output. Write the IN drive (5.3). Then plug it into the box and run:

receive
cat -v ~/in/<stamp>/hello.txt
sudo blockdev --getro /dev/disk/by-partlabel/AIRGAP-IN

Expected: receive prints Received into /home/<you>/in/<stamp>: followed by hello.txt. That line tells you the <stamp> to use in the next command. cat -v prints first crossing. blockdev prints 1, meaning Linux now refuses to write to the drive. Unplug it.

Now the other direction. Plug the OUT drive into the box:

echo "reply from the box" > ~/out/reply.txt
send

Expected: Sent. Moved to /home/<you>/sent/<stamp>: followed by a listing that shows reply.txt. Unplug the drive and read it on the connected machine (5.3). Expected there: ./ and ./reply.txt. Open the file with cat -v ~/airgap/out/<date>/reply.txt to see the text.

Both directions work. Every later crossing uses these same steps.

8.6 Check

ip -br link
lsmod | grep -E '^(r8169|igc|e1000e|usbnet|r8152|lan78xx|ipheth|cfg80211|bluetooth) '

ip -br link lists every network interface Linux knows about. lsmod lists the loaded drivers, and grep looks for the blocked ones.

Expected: ip prints exactly one line, for the loopback interface, which is the computer talking to itself and is always there:

lo               UNKNOWN        00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>

lsmod | grep prints nothing. If ip shows any other line, a network device is still active: check the firmware setting (6.2) and the driver list (8.2).

9. Offline package updates

On a normal computer, apt downloads two things from Devuan’s servers: the package lists, a catalogue of every package available, signed by Devuan; and then the packages you choose. Every package’s checksum is in the signed lists, so apt can check each package it downloads.

The box does exactly the same, except that its “server” is a folder on its own disk, /srv/mirror, laid out exactly like Devuan’s real archive at deb.devuan.org. The connected machine downloads files into the same layout, and the IN drive carries them across. apt on the box then checks Devuan’s signature on the lists and the checksum of every package, exactly as it would online. So a damaged or tampered file is refused, whatever happened on the connected machine.

There are two kinds of round:

  • A lists round brings a fresh catalogue. It takes one crossing.
  • A packages round brings the packages the box asks for. It takes two crossings: the box writes a shopping list of the exact files it needs, which goes out on the OUT drive; the connected machine downloads exactly those files, and they come back on the IN drive.
Lists round (one crossing)
  connected machine: download lists   --- IN --->   box: receive, copy into mirror, apt-get update

Packages round (two crossings)
  box: write the shopping list        --- OUT -->   connected machine: download the files
  connected machine                   --- IN --->   box: receive, copy into mirror, install

9.1 Point apt at the local mirror (box)

Create the mirror folder and check for extra software sources:

sudo mkdir -p /srv/mirror
ls /etc/apt/sources.list.d/

Expected: mkdir prints nothing, and ls prints nothing because that folder is empty. If ls does list any files, move them out of apt’s way:

sudo mkdir -p /etc/apt/disabled
sudo mv /etc/apt/sources.list.d/* /etc/apt/disabled/

Expected: no output.

Then edit apt’s main list of sources:

sudo nano /etc/apt/sources.list

The file probably contains a line starting deb cdrom: and a few comments. Put a # at the start of every line already in the file; this turns them into comments, which apt ignores. Then add these three lines at the end:

deb [target=Packages by-hash=no] file:/srv/mirror/merged excalibur main contrib non-free non-free-firmware
deb [target=Packages by-hash=no] file:/srv/mirror/merged excalibur-updates main contrib non-free non-free-firmware
deb [target=Packages by-hash=no] file:/srv/mirror/merged excalibur-security main contrib non-free non-free-firmware

What each part means:

  • deb says this line is a source of ready-made packages.
  • target=Packages makes apt read only the package lists, not translations or software-centre extras, so the mirror needs fewer files. by-hash=no makes apt look for the lists under their plain names.
  • file:/srv/mirror/merged is where the packages are: a folder on this box, instead of a web address.
  • excalibur is the codename of Devuan 6, the release. excalibur-updates and excalibur-security are its channels for fixes.
  • main contrib non-free non-free-firmware are the sections of the archive to use. non-free is needed for the NVIDIA driver.

If a newer Devuan stable release has replaced Excalibur by the time you read this, use its codename here and in 9.2.

Save and exit. Do not run apt-get update yet: the mirror stays empty until the first lists round.

9.2 Lists round

Check the box’s clock first (9.5).

On the connected machine, first create the crossing folder and move into it. Type this, with your date in place of <date>:

mkdir -p ~/airgap/in/<date> && cd ~/airgap/in/<date>

Expected: no output. Then download the signed release files and the package lists they name. This loop downloads, for each of the three channels, the signed index file called InRelease, reads it to find the names of the package lists for this kind of computer (amd64), and downloads those too. You do not need to understand it; paste the whole box at once, in the same terminal:

for s in excalibur excalibur-updates excalibur-security; do
    curl -fsSL --create-dirs -o "merged/dists/$s/InRelease" "http://deb.devuan.org/merged/dists/$s/InRelease"
    for f in $(awk '/^SHA256:/ {x=1; next} /^[^ ]/ {x=0} x && $3 ~ /^[a-z-]+\/binary-(amd64|all)\/Packages\.(xz|gz)$/ {print $3}' "merged/dists/$s/InRelease"); do
        curl -fsSL --create-dirs -o "merged/dists/$s/$f" "http://deb.devuan.org/merged/dists/$s/$f"
    done
done
find merged -type f | sort

The addresses start with http, not https, on purpose: the files carry Devuan’s signature, which apt checks on the box, so the connection itself does not need to be secure.

Expected: nothing while it downloads, then a list of files: an InRelease file for each of the three channels, plus Packages.xz and Packages.gz files for each of their sections. A line starting with curl: is a download error: run the whole box again. Then write the IN drive (5.3).

On the box, plug in the IN drive and run:

receive
sudo cp -r --remove-destination ~/in/<stamp>/merged /srv/mirror/
sudo chmod -R a+rX /srv/mirror
sudo apt-get update

What these do: cp copies the new files into the mirror, replacing older copies. chmod makes everything in the mirror readable, because apt reads the mirror with a special low-privilege account. apt-get update reads the new lists and checks Devuan’s signature on them.

Expected: receive lists the files. cp and chmod print nothing. apt-get update prints a Get: line for each release file and package list it reads from file:/srv/mirror/merged, then Reading package lists... Done. There must be no line starting with Err:, W: or E:; section 16 covers them. Lines starting with N: are only notices. An InRelease line may appear twice, and on later rounds, lists that have not changed show Hit: instead of Get:. Both are normal.

Check that the lists arrived:

apt-cache policy nvidia-driver

Expected: a Candidate: line followed by a version number. Candidate: (none) means the lists did not install: read the output of apt-get update again and repeat the round.

Check that the lists are fresh. Devuan’s signature stops tampering, but a hacked connected machine could still hand over old, genuine lists, and so hold back security fixes. Each list file records when Devuan published it:

grep -H '^Date:' /var/lib/apt/lists/*_InRelease

Expected: one Date: line per channel, for example:

/var/lib/apt/lists/_srv_mirror_merged_dists_excalibur-security_InRelease:Date: Sun, 01 Nov 2026 08:12:34 UTC

Phone check: on your phone, open http://deb.devuan.org/merged/dists/excalibur-security/InRelease. Near the top is a Date: line. Compare it with the box’s line for excalibur-security. They should match, or the box’s should be at most a day older if Devuan has published since you downloaded. If the box’s date is days older, the lists are stale: fetch them again.

9.3 Packages round

Check the box’s clock first (9.5). Then plug the OUT drive into the box and write the shopping list there. <packages> is the list of packages to install in this round (9.4 gives the first one). For routine updates, leave out the second line. Run the lines one at a time.

sudo apt-get --print-uris -qq full-upgrade > ~/out/uris.txt
sudo apt-get --print-uris -qq install <packages> >> ~/out/uris.txt
sort -u -o ~/out/uris.txt ~/out/uris.txt
wc -l ~/out/uris.txt
send

What these do: --print-uris makes apt work out every file it would need, and write their addresses into the file uris.txt instead of downloading them. The first line asks for everything needed to bring the system fully up to date; the second, for the new packages; >> adds to the file instead of replacing it. sort -u removes duplicates. wc -l counts the lines; if it prints 0, the box already has everything, so skip send and the rest of this round. send writes the list to the OUT drive.

Expected: the apt-get lines print nothing. wc prints the number of files requested and the file name. send prints Sent. Moved to ... and lists uris.txt. E: Unable to locate package means the lists are missing or out of date, or a package name is misspelt: check the spelling, or do a lists round first.

On the connected machine, read the OUT drive (5.3) and look at the request before using it:

less ~/airgap/out/<date>/uris.txt

Expected: plain text, one line per file. Each line starts with 'file:/srv/mirror/merged/pool/, followed by a file name, a size and a checksum such as MD5Sum:.... Some names contain %2b or %7e: apt writes + and ~ that way in addresses. Press q to quit. If you see anything else, stop and find out why before going on.

Download every file it names. First create a new crossing folder and move into it, and tell the terminal where the request is. Type these two lines, with a new crossing date (1.2) in the first, and the folder you just unpacked the request into in the second:

mkdir -p ~/airgap/in/<date> && cd ~/airgap/in/<date>
uris=~/airgap/out/<date>/uris.txt

The second line stores the request’s location under the name uris, for the next box. Expected: no output. If you open a new terminal before the next box, type both lines again.

Then paste this whole box, in the same terminal:

tr -d "'" < "$uris" | while read -r uri rest; do
    enc=${uri#file:/srv/mirror/}
    path=$(printf '%s\n' "$enc" | sed 's/%2[bB]/+/g; s/%7[eE]/~/g; s/%25/%/g')
    curl -fsSL --create-dirs -o "$path" "http://deb.devuan.org/$enc" && echo "ok $path" || echo "FAILED $path"
done

The loop reads the list line by line, downloads each file from Devuan, and saves it under its real name, with + and ~ written out, because that is the name apt looks for in the mirror.

Expected: one ok line per file and no FAILED lines. If any failed, run the whole box again. Then write the IN drive (5.3).

On the box, plug in the IN drive and install:

receive
sudo cp -r --remove-destination ~/in/<stamp>/merged /srv/mirror/
sudo chmod -R a+rX /srv/mirror
sudo apt-get full-upgrade
sudo apt-get install <packages>

Use the same <packages> list as in the request, or leave out the last line for a routine update.

Expected: each apt-get command lists what it will change, then a line like 0 upgraded, 12 newly installed, 0 to remove and 0 not upgraded., then Need to get 0 B/<size> of archives. and Do you want to continue? [Y/n]. Answer y. It then shows a Get: line for each package it reads from file:/srv/mirror/merged, and unpacks and sets them up. Need to get 0 B means everything it needs is already on the box. If there is nothing to upgrade, full-upgrade only reports 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.

Problems: a line with Err: and File not found means a package is missing from the mirror: do another packages round with the same commands. Hash Sum mismatch means a package file is damaged or was tampered with, and apt-get refuses it: delete the file named in the message from /srv/mirror and fetch it again.

You can delete each received folder once its contents are in the mirror: rm -r ~/in/<stamp>.

9.4 First packages round: driver, toolkit and build tools

Do a packages round (9.3), using this as <packages>:

linux-headers-amd64 nvidia-kernel-dkms nvidia-driver nvidia-smi nvidia-cuda-toolkit nvidia-cuda-toolkit-gcc build-essential cmake git firefox-esr qrencode util-linux-extra

This is one long line; type the package names separated by single spaces. Besides the NVIDIA driver and the CUDA toolkit (section 10), it brings build-essential and cmake, the tools for building llama.cpp; git, which the build uses to record its version; firefox-esr, the web browser for the chat page; qrencode, for getting short text out as QR codes (13.5); and util-linux-extra, for hwclock (9.5). The download is several GB, most of it the CUDA toolkit, so the IN drive crossing takes a while. Section 10 describes what you will see during the installation.

9.5 Clock and expiry

The box has no internet time, so its clock slowly drifts. apt cares about the date: it refuses package lists dated in the future (“not valid yet”) and lists past their expiry date (“expired”). Check the clock before every round:

date

Expected: one line with the current date and time, marked IST (Indian Standard Time).

If it is more than a few minutes off, set it, typing the current Indian date and time in this format, then save it to the motherboard’s clock. Until the first packages round (9.4) has installed hwclock, run only the first line, and also set the clock in the firmware setup screen as described below.

sudo date -s "<YYYY-MM-DD HH:MM>"
sudo hwclock --systohc

For example, sudo date -s "2026-11-01 14:35" for 2:35 PM on 1 November 2026.

Expected: date -s prints the new time. hwclock prints nothing.

To set the clock in the firmware setup screen, open it (section 6). On Gigabyte boards, the System Info. tab has System Date and System Time. Set them in UTC, not Indian time: the box keeps the motherboard clock in UTC, which is 5 hours 30 minutes behind Indian time. For example, at 2:35 PM in India, set 09:05. Save with F10.

Run apt-get update on the box within a few days of fetching the lists, before they expire.

10. NVIDIA driver and CUDA

The driver lets Linux use the RTX 3090. CUDA is NVIDIA’s toolkit for running general calculations, such as AI, on the graphics card; llama.cpp is built with it in section 11. The first packages round (9.4) brought in:

PackagePurpose
nvidia-driver, nvidia-kernel-dkmsThe driver. DKMS builds its kernel module on the box.
linux-headers-amd64Files describing your kernel, which DKMS needs to build the module.
nvidia-smiA tool that shows the card’s status and changes its settings.
nvidia-cuda-toolkitThe CUDA compiler and libraries, for building llama.cpp.
nvidia-cuda-toolkit-gccThe version of the GCC compiler that this CUDA release accepts, available as cuda-gcc and cuda-g++.

The firmware that the driver loads onto the card, firmware-nvidia-gsp, comes in automatically with them.

DKMS builds the part of the driver that goes into the kernel so that it fits your exact kernel. It does this again by itself whenever a kernel update arrives.

During the installation you will see:

  • DKMS building the NVIDIA module (Debian calls it nvidia-current) for the newest installed kernel. It takes several minutes and must not end with an error. Lines about a signing key or MOK are normal; with Secure Boot off they change nothing. If an older kernel has no headers installed, DKMS says it skipped that kernel. That is fine.
  • A full-screen notice titled “Conflicting nouveau kernel module loaded”, telling you to restart after the installation. nouveau is the free driver Linux uses for NVIDIA cards by default; the NVIDIA driver replaces it, and that needs a restart. This is normal. Press Enter.

Restart the box, then run:

nvidia-smi

Expected: a table describing the card. Look for three things in it: the name NVIDIA GeForce RTX 3090, about 24576MiB as the total memory, and the words Driver Version and CUDA Version, each followed by a number.

Then check the CUDA compiler and its matching GCC:

nvcc --version
cuda-g++ --version

Expected: nvcc prints a few lines, one of them like Cuda compilation tools, release 12.<n>, V12.<n>.<n>. cuda-g++ prints a first line ending in a version number that starts with 13.

10.1 Optional: lower the power limit

Generating text is limited by how fast the card’s memory is, not by the main GPU chip. Lowering the 3090’s power limit from 350 W to 280 W costs little speed and removes a lot of heat and noise.

sudo nvidia-smi -pm 1
sudo nvidia-smi -pl 280

The first command turns on persistence mode, which keeps the driver loaded, and its settings applied, even when no program is using the card. The second sets the power limit in watts.

Expected: a line confirming persistence mode is enabled, then:

Power limit for GPU 00000000:<bus id> was set to 280.00 W from <default> W.
All done.

<default> is your card’s stock limit: 350.00 on most 3090s, more on some factory-overclocked models.

The limit goes back to the default at every start. Devuan runs the file /etc/rc.local at the end of every start, so put the two commands there:

sudo nano /etc/rc.local

If the file already has content, add the two nvidia-smi lines just above its final exit 0 line. If nano opens an empty file, type all of this:

#!/bin/sh
nvidia-smi -pm 1
nvidia-smi -pl 280
exit 0

Save and exit, then make the file runnable:

sudo chmod 755 /etc/rc.local

Expected: no output. After the next restart, nvidia-smi -q -d POWER | grep -i 'power limit' shows 280.00 W as the current limit.

11. Build llama.cpp

llama.cpp is the program that loads a model and generates text. It includes a small web server with a chat page, which you open in Firefox on the box. You will build it from its source code on the box itself, so that you can check that the code is exactly what its developers published.

llama.cpp publishes a versioned release every week or two, with a tag (1.1) like v0.7.0, and a nightly build for every change, with a tag like b11669. Use the newest versioned release. The chat page does not come with the source code: it comes as a separate archive attached to the nightly build that matches the release, and the box needs both. Building the chat page yourself would mean running a tool called npm, which pulls in hundreds of JavaScript packages, exactly what rule 6 forbids (5.1).

11.1 Get the source and the chat page (connected machine)

  1. On your phone, open https://github.com/ggml-org/llama.cpp/releases and find the release marked Latest. Its tag starts with v. Its notes name the matching nightly build, for example Nightly build: b11669. Write down both tags. Doing this on the phone means a hacked connected machine cannot steer you to an older release. Read the release notes, and the repository’s Security tab, for anything about GGUF files or model loading.

  2. Download the source code and select the release. git clone downloads the whole project with its full history, git checkout switches to the release, and the last two commands show which commit you are on:

mkdir -p ~/airgap && cd ~/airgap
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
git checkout <tag>
git log -1 --format='%H %cd'
git tag --points-at HEAD

<tag> is the release tag you wrote down, starting with v.

Expected: git clone ends with a line like Resolving deltas: 100% (...), done. git checkout prints a note about being in “detached HEAD” state; that sounds alarming but is normal when you check out a tag. git log prints the full 40-character commit hash and its date. git tag --points-at HEAD prints both tags, the nightly one and the release. If you kept the folder from a previous time, skip the git clone line, run cd ~/airgap/llama.cpp && git fetch --tags instead, and continue with git checkout.

  1. Pack the release into one file, a git bundle. It carries the release and its full history, which the build uses to record its version, and it lets the box check every file against the commit hash:
mkdir -p ~/airgap/in/<date>
git bundle create ~/airgap/in/<date>/llama.cpp-<tag>.bundle <tag>

Expected: lines counting, compressing and writing objects, ending with a line that starts with Total. The bundle is a few hundred MB.

  1. Download the chat page archive from the nightly build. <nightly> is the nightly tag you wrote down, starting with b:
cd ~/airgap/in/<date>
curl -fLO https://github.com/ggml-org/llama.cpp/releases/download/<nightly>/llama-<nightly>-ui.tar.gz
sha256sum llama-<nightly>-ui.tar.gz

Expected: a progress meter, then one line with a 64-character checksum and the file name. Now open the nightly build’s release page: tap the nightly tag where the release notes from step 1 mention it, or open https://github.com/ggml-org/llama.cpp/releases/tag/<nightly> directly. Under Assets, find llama-<nightly>-ui.tar.gz; you may need to click “Show all assets”. GitHub shows sha256: and a checksum next to it. Every character must match yours.

  1. Write the IN drive (5.3).

11.2 Check and unpack (box)

Plug in the IN drive:

receive
sha256sum ~/in/<stamp>/llama-<nightly>-ui.tar.gz

Expected: receive lists the bundle and the archive. sha256sum prints the checksum. Phone check: compare it with the sha256: value next to the archive on the nightly build’s release page.

If you built llama.cpp before, move the old build aside first: rm -rf ~/src/llama.cpp.old && mv ~/src/llama.cpp ~/src/llama.cpp.old. On the first build, skip this.

Now copy the source out of the bundle, without unpacking any files yet:

mkdir -p ~/src
git clone --no-checkout --branch <tag> ~/in/<stamp>/llama.cpp-<tag>.bundle ~/src/llama.cpp
git -C ~/src/llama.cpp rev-parse HEAD

Expected: git clone prints Cloning into '/home/<you>/src/llama.cpp'..., then lines that count up to 100%. rev-parse prints a 40-character commit hash.

Phone check: open the release on GitHub. Next to its tag is a short commit code, seven characters long. Tap it. The commit page shows the full 40-character hash, which is also visible in the page’s address. All 40 characters must match the box’s. While copying out of the bundle, git recomputes the hash of every file, so a matching commit hash proves every file in the source is exactly what was published. Only then unpack the files:

git -C ~/src/llama.cpp reset --hard

Expected: HEAD is now at followed by the short commit code and the commit’s title.

Unpack the chat page into the folder where the build looks for it:

mkdir -p ~/src/llama.cpp/tools/ui/dist
tar -xzf ~/in/<stamp>/llama-<nightly>-ui.tar.gz -C ~/src/llama.cpp/tools/ui/dist --strip-components=1
ls ~/src/llama.cpp/tools/ui/dist

Expected: no output from mkdir and tar, then a list that includes index.html, sw.js, manifest.webmanifest, build.json, a folder _app and a set of image files.

11.3 Build (box)

Building has two steps: configure, where the build tool CMake checks your system and decides what to build, and build, where the compiler does the work.

Configure:

cd ~/src/llama.cpp
cmake -B build \
    -DCMAKE_BUILD_TYPE=Release \
    -DGGML_CUDA=ON \
    -DCMAKE_CUDA_ARCHITECTURES=86 \
    -DCMAKE_CUDA_HOST_COMPILER=/usr/bin/cuda-g++ \
    -DLLAMA_OPENSSL=OFF \
    -DLLAMA_USE_PREBUILT_UI=OFF \
    -DLLAMA_BUILD_IS_DEV=OFF \
    -DCMAKE_INSTALL_RPATH='$ORIGIN' \
    -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON

The cmake command is one command spread over several lines (1.2). If you prefer, type it as a single line: cmake -B build followed by all the -D... options, separated by spaces, without the \ signs.

What the options do:

OptionWhy
GGML_CUDA=ONBuild the part of llama.cpp that uses the NVIDIA card.
CMAKE_CUDA_ARCHITECTURES=86Compile graphics card code only for Ampere, the 3090’s generation. Saves a lot of build time.
CMAKE_CUDA_HOST_COMPILER=/usr/bin/cuda-g++Use the GCC version that Devuan’s CUDA accepts. The default GCC is too new for it.
LLAMA_OPENSSL=OFFBuild without secure web connections, so the model-download feature cannot work. The box could not use it anyway. Releases before January 2026 used LLAMA_CURL=OFF for this.
LLAMA_USE_PREBUILT_UI=OFFNever try to download the chat page during the build; use the files from 11.2.
LLAMA_BUILD_IS_DEV=OFFReport the release version, for example 0.7.0, rather than 0.7.0-dev. Leave it out if you ever build a nightly instead of a release.
CMAKE_INSTALL_RPATH='$ORIGIN' and CMAKE_BUILD_WITH_INSTALL_RPATH=ONMake each program look for llama.cpp’s libraries in its own folder, so a build keeps working after you move or rename it. llama.cpp’s own Linux release builds do the same. Keep the single quotes.

Expected: many lines starting with --, including -- llama.cpp version: <version> and a line identifying the CUDA compiler as NVIDIA with a 12.x version, ending with lines that start -- Configuring done and -- Generating done, then -- Build files have been written to: /home/<you>/src/llama.cpp/build.

These warnings are harmless: ccache not found; “Manually-specified variables were not used by the project”, which only means that this release renamed or dropped one of the options above.

Build:

cmake --build build --config Release -j 6

-j 6 runs six compile jobs at once, one per CPU core. More jobs can run 16 GB of RAM out of memory.

Expected: early on, a line -- UI: using pre-built assets from /home/<you>/src/llama.cpp/tools/ui/dist. Then progress lines such as [ 23%] Building CUDA object .... The graphics card code is slow to compile; allow 10 to 40 minutes. Compiler warnings, which scroll past in large numbers, are normal. Only lines containing error: matter, and they stop the build. It finishes with lines like [100%] Built target llama-server.

If you see UI: no assets available - building without an embedded UI instead, the chat page files are missing: redo the last step of 11.2 and build again. The message names the folder the files belong in, in case a newer release has moved it.

Check:

ls build/bin
build/bin/llama-server --version

Expected: the list includes llama-server, llama-cli, llama-bench and llama-quantize. --version may first print lines naming the CUDA device, then prints a line like version: 0.7.0 (build <number>, commit <hash>) and a line starting built with GNU.

12. Models

A model is the AI itself: one large file. Its size is given in parameters, the numbers inside it: a 27B model has 27 billion. More parameters generally means a more capable model, and a bigger file that needs more VRAM.

To fit bigger models on the card, people publish quantized versions, where each number is stored with fewer bits. The name says how much: Q8_0 is barely shrunk and almost as good as the original; Q4_K_M is about a third of the original size and a little worse; lower numbers are smaller still and noticeably worse.

The model also needs room in VRAM for its context: the text it keeps in mind at once, meaning your conversation so far plus any document you paste in. Context is measured in tokens; a token is roughly three quarters of an English word, so a 16K (16,384-token) context holds roughly 12,000 words.

12.1 What fits in 24 GB

The model file must fit in VRAM with 2 to 4 GB left over for the context.

Model sizeQuantizationFile sizeOn one 3090
7B to 9BQ8_08 to 10 GBYes, with long context
14BQ6_Kabout 12 GBYes
20B to 32BQ4_K_Mabout 14 to 20 GBYes, with 8K to 16K context
70BQ4_K_Mabout 42 GBNo. Needs two 3090s

For your first model, pick a 7B to 9B model at Q8_0. It downloads and crosses quickly, and it lets you test the whole chain, from download to chat, before you move a 20 GB file. Once that works, a 27B to 32B model at Q4_K_M is the usual choice for daily use on one 3090.

12.2 Where models come from

Models are published on a website called Hugging Face (https://huggingface.co). Each model has a page called a repository, and many models come in several repositories: the original from the organisation that made it, and converted or modified versions from other people. To find a GGUF version, search the site for the model’s name followed by “GGUF”.

In order of trust:

  1. GGUF files published by the model’s own organisation on Hugging Face.
  2. GGUF files from a well-known quantizer with a long record for that model family.
  3. Community fine-tunes and merges, including “abliterated” or “uncensored” versions. Someone else changed the model, and you cannot check what they did.

The strongest option is to download the publisher’s original model and make the GGUF yourself (Appendix A). That is for later; do not start there.

A matching SHA256 proves you received exactly the file that was published. It does not prove the file is safe.

12.3 Download and hash (connected machine)

In a browser: open the model’s repository and click the “Files and versions” tab. It lists several .gguf files, one per quantization, with their sizes; pick one that fits (12.1). Click the file’s name. Its page shows a details box headed “Xet Pointer Details” or “Git LFS Details”. Note the value labelled SHA256 (not the one labelled Xet hash). Then click the download button; the file goes to your Downloads folder. When it has finished, move it into the crossing folder:

mkdir -p ~/airgap/in/<date>
mv ~/Downloads/<file>.gguf ~/airgap/in/<date>/

Expected: no output.

Or with the hf command (4.4). <org>/<repo> is the repository’s name as shown at the top of its page, and <file> is the file’s name without .gguf:

mkdir -p ~/airgap/in/<date> && cd ~/airgap/in/<date>
hf download <org>/<repo> <file>.gguf --local-dir .

Expected: a progress bar, then the path of the downloaded file. Some models, such as Llama and Gemma, are gated: you must first accept the licence on the model’s page while logged in to Hugging Face, then log in on the command line with hf auth login and an access token from your Hugging Face account settings.

Work out its checksum:

cd ~/airgap/in/<date>
sha256sum <file>.gguf | tee <file>.gguf.sha256

tee shows the result on screen and also saves it in a file next to the model, which crosses with it.

Expected: one line, a 64-character checksum followed by the file name. Compare it with the SHA256 on the file’s Hugging Face page, in groups of eight characters. Every character must match. If not, delete the file and download it again.

Large models come in parts named like <name>-00001-of-00003.gguf. Download, hash and compare every part, and keep the parts together.

hf may leave a hidden .cache folder in the crossing folder. Remove it whether or not you can see it; the command does nothing if there is none: rm -rf ~/airgap/in/<date>/.cache. Then write the IN drive (5.3). A large file takes several minutes.

12.4 Bring it in (box)

Plug in the IN drive and run the commands below. receive prints nothing for several minutes while it copies a large file; that is normal.

receive
cd ~/in/<stamp>
sha256sum <file>.gguf

Expected: one line, a 64-character checksum followed by the file name. The box computed it from the file itself, so it does not depend on anything the connected machine wrote. Phone check: compare it with the SHA256 on the model’s Hugging Face page. Every character must match. For a split model, run sha256sum on every part and check each one.

Move it into the models folder. The first two lines create the folder and make you its owner, so you can put files there without sudo:

sudo mkdir -p /srv/models
sudo chown "$USER": /srv/models
mv <file>.gguf <file>.gguf.sha256 /srv/models/

Expected: no output. For a split model, move every part, and point llama.cpp at the first part (the file ending -00001-of-...gguf); it finds the others.

13. Run it

13.1 Launcher

A launcher is a tiny script that starts llama.cpp’s server with the right settings, so you do not have to type them every time. First run ls /srv/models and note the model’s exact file name: you need it in a moment, and nano cannot complete names with Tab.

mkdir -p ~/bin
nano ~/bin/ai-server

Type this, replacing <file> with your model’s file name (without the brackets, and keeping .gguf), then save and exit:

#!/bin/sh
# Start llama-server, reachable from this machine only.
MODEL=/srv/models/<file>.gguf
exec "$HOME/src/llama.cpp/build/bin/llama-server" \
    --model "$MODEL" \
    --n-gpu-layers 99 \
    --ctx-size 16384 \
    --host 127.0.0.1 \
    --port 8080

What the options do: --model names the model file. --n-gpu-layers 99 puts the whole model on the graphics card (models are built from layers, and 99 is more than any model here has). --ctx-size 16384 sets the context (section 12). --host 127.0.0.1 makes the server reachable only from the box itself, and --port 8080 picks the door number (1.1).

Make it runnable and start it:

chmod 755 ~/bin/ai-server
~/bin/ai-server

Expected: many lines scroll past while the model loads. Among them (the exact wording varies between releases):

ggml_cuda_init: found 1 CUDA devices (Total VRAM: <n> MiB):
  Device 0: NVIDIA GeForce RTX 3090, compute capability 8.6, VMM: yes, VRAM: <n> MiB
load_tensors: offloaded 65/65 layers to GPU
srv          main: listening on http://127.0.0.1:8080

The two numbers in offloaded N/N layers must be equal; 65 is only an example. If the first is lower, part of the model runs on the CPU and will be slow. The last line means the server is ready.

Recent releases can squeeze a model into VRAM by themselves when you leave out --n-gpu-layers and --ctx-size, by moving layers to the CPU or shrinking the context. The launcher sets both on purpose: if the model does not fit, the server stops with an out-of-memory error instead of quietly running slower.

These lines are normal:

  • a line saying the context is smaller than n_ctx_train and that “the full capacity of the model will not be utilized”. It only means you chose a shorter context than the model’s maximum;
  • a warning starting security: no API key is set and CORS allows all origins. Only programs on this machine can reach the server.

Do not add --tools or --agent to the launcher. They let the model read and write files and run commands on the box.

Leave the terminal open while you use the AI; closing it, or pressing Ctrl+C in it, stops the server.

13.2 Use it

Open Firefox on the box (Applications, then Internet, then Firefox ESR) and type http://127.0.0.1:8080 in the address bar. The llama.cpp chat page opens. Type a question at the bottom and press Enter. Bookmark the page (Ctrl+D) so you can open it with one click next time.

13.3 Check it listens only on this machine

In a second terminal window:

ss -ltn

ss -ltn lists the doors (ports) on which programs are waiting for connections.

Expected: on the line for port 8080, the Local Address column shows 127.0.0.1:8080. If it shows 0.0.0.0:8080 or *:8080, the server would accept connections from other machines: fix the --host value in the launcher. Other lines whose Local Address shows 127.0.0.1 or [::1] belong to local services and are fine.

13.4 Check speed

Stop the server first with Ctrl+C in its terminal, then:

~/src/llama.cpp/build/bin/llama-bench --model /srv/models/<file>.gguf --n-gpu-layers 99

Expected: after a minute or so, a table with a pp512 row (how fast it reads a prompt) and a tg128 row (how fast it writes), in tokens per second. For a 27B model at Q4_K_M on a 3090, tg128 in the 30s is normal. Single-digit numbers mean layers are running on the CPU.

13.5 Getting text out

In order of preference:

  1. Read it on the screen. Nothing leaves.
  2. For short text, use QR codes. Select the text in the chat page and copy it with Ctrl+C. Open a new file with nano ~/answer.txt, paste with Ctrl+Shift+V, then save and exit. Then cut the file into pieces small enough to scan reliably, and show each piece as a QR code:
cd ~
split -C 500 answer.txt part-
ls part-*
qrencode -t ANSIUTF8 < part-aa

split -C 500 cuts the file into pieces of at most 500 bytes, about 500 letters of English text, named part-aa, part-ab and so on. A piece can end in the middle of a word, so when you put the pieces back together on the phone, join them without adding spaces. qrencode draws a QR code in the terminal.

Expected: split prints nothing. ls lists the pieces. qrencode draws a QR code. Maximise the terminal window, scan the code with your phone’s camera, then show the next piece with qrencode -t ANSIUTF8 < part-ab, and so on. If the whole square does not fit on the screen, make the terminal text smaller with Ctrl+minus and draw it again. Delete the pieces afterwards with rm part-*.

  1. For longer text, use the OUT drive, and only for plain text you have read first. Plug in the OUT drive:
cp ~/answer.txt ~/out/
send

Expected: Sent. Moved to ... and a listing with answer.txt. On the connected machine, read the OUT drive (5.3) and open the file with a plain text viewer, for example less ~/airgap/out/<date>/answer.txt.

13.6 Using it day to day

Each time you want to use the AI:

  1. Switch on the box, type the encryption passphrase, and log in.
  2. Open a terminal and run ~/bin/ai-server. Wait for the listening on line.
  3. Open Firefox and click the bookmark (13.2).

When you are done, press Ctrl+C in the server’s terminal, then shut down from Applications, then Log Out, then Shut Down.

To switch to another model, bring it in (12.3 and 12.4), then edit the launcher with nano ~/bin/ai-server and change the file name on the MODEL= line.

14. Maintenance

Once a month, and whenever llama.cpp announces a security fix, spend an evening on these. Allow two to three hours the first few times; it gets quicker.

  1. Check and set the clock (9.5).
  2. Update the software. Do a lists round (9.2), then a packages round (9.3) with only the full-upgrade line. If a new kernel or NVIDIA driver arrived, restart and check nvidia-smi (section 10).
  3. Update llama.cpp. Build the newest release (section 11). Keep the previous build, ~/src/llama.cpp.old, until the new one works.
  4. Check for a new BIOS on the board vendor’s page (6.1). A BIOS update resets all its settings: afterwards, redo 6.2 and 6.3, then run the checks in section 15.
  5. Clear out what you no longer need: received folders (rm -r ~/in/<stamp>) and the package files already installed from the mirror (sudo find /srv/mirror/merged/pool -name '*.deb' -delete). The mirror’s package lists stay.
  6. Log every crossing (Appendix B).
  7. Update the connected machine too, every week or so (4.2, step 6).

15. Final checks

Run these after setup and after any change. Each one checks a promise this guide makes.

CheckHowExpected
Only loopback networkingip -br linkOne line, lo
No network drivers loadedThe lsmod command from 8.6No output
Disk encryptedlsblk -o NAME,FSTYPE,FSVER,MOUNTPOINTS/ sits inside crypto_LUKS
Drives carry no filesystemPlug in a transfer drive, run lsblk -o NAME,FSTYPE,PARTLABEL,MOUNTPOINTSEmpty FSTYPE and MOUNTPOINTS
IN drive read-only after receivesudo blockdev --getro /dev/disk/by-partlabel/AIRGAP-IN1
apt uses only the local mirrorapt-cache policyEvery source line names file:/srv/mirror/merged; none names http
GPU readynvidia-smiRTX 3090, about 24576MiB
Server local onlyss -ltn while the server runs127.0.0.1:8080
FirmwareSetup screenLAN off, Secure Boot off, administrator password set

16. Troubleshooting

Find the symptom, then try the fix. If nothing here matches, read the exact error message slowly: it usually names the file or the step that went wrong.

16.1 General

SymptomLikely causeFix
Typing your password shows nothingNormalType it and press Enter (1.2).
command not foundA typo in the command’s name, or the program is not installed yetCheck the spelling. If it is right, the package may come in a later step.
Permission deniedThe command needs sudo, or a script was not made runnablePut sudo in front where the guide does. For the scripts, run the chmod 755 step again.
No such file or directoryA wrong path, a placeholder left in, or a drive not plugged inCheck the path with ls, one folder at a time. Replace every <...>.
syntax error near unexpected tokenA placeholder was typed with its < and >, or a quote or bracket is missingReplace placeholders completely (1.2). Compare the command with the guide, character by character.
The terminal shows > and waitsIt is waiting for the rest of a commandIf you are in the middle of a loop or a command whose lines end in \, keep typing (1.2). Otherwise a closing quote is probably missing: press Ctrl+C and type the command again.
The monitor stays black when the box startsThe monitor is plugged into the motherboard, or the BIOS is too old for the CPUPlug the monitor into the graphics card. If it already is, have a shop update the BIOS (3.2).
The boot menu does not show the installer stickThe stick was not written properlyWrite it again (7.2), and pick the entry starting with UEFI:.
Forgot the firmware administrator passwordResetting it means clearing the motherboard’s settings memory (the CMOS), which involves opening the box. See the motherboard manual, or ask a shop.
Forgot the encryption passphraseNothing can recover the data. Install again from section 7.
Installer finds no network on the boxThe LAN is disabledExpected. Continue.

16.2 Drives and crossings

SymptomLikely causeFix
receive or send says the drive was not foundWrong drive, or it is not plugged inls /dev/disk/by-partlabel/ should show AIRGAP-IN or AIRGAP-OUT. If neither appears, prepare the drive again (5.2).
receive says Nothing was keptA file name with accented letters or symbols, a bad write, or an archive that fails the checks in 8.5If the lines after it show a name containing M-, rename that file on the connected machine (5.3). Otherwise write the folder to the IN drive again. If it happens again with plain names, stop trusting the connected machine.
A checksum on the box does not match the publisher’s, or sha256sum -c says FAILEDBad copy, bad download or a tampered fileWrite the IN drive again. If it still fails, download again on the connected machine. Never use the file anyway.

16.3 Updates

SymptomLikely causeFix
apt-get update shows an Err: line, or a warning about signaturesDamaged, missing or tampered list filesDo the lists round again. Never add trusted=yes to the sources or use --allow-unauthenticated: they switch off the signature checks that protect the box.
The box’s excalibur-security date is days older than the phone’sThe connected machine handed over stale listsFetch the lists again. If it keeps happening, stop trusting the connected machine.
apt says a release file is expired or not valid yetLists too old, or the box’s clock is wrongFix the clock (9.5) and do the lists round again.
E: Unable to locate packageLists missing or out of date, or a misspelt package nameCheck the spelling. Otherwise do a lists round first.
apt-get install shows Err: and File not foundA package is not in the mirrorDo another packages round with the same commands.
Hash Sum mismatchA damaged or tampered package fileDelete the file named in the message from /srv/mirror and fetch it again.

16.4 Driver and build

SymptomLikely causeFix
Black screen after installing the NVIDIA driverThe module was not built, or nouveau is still in controlPress Ctrl+Alt+F2 for a text login, and log in with your user name and password. Run dkms status: it should list the NVIDIA module as installed for your kernel. Check that linux-headers-amd64 is installed with dpkg -l linux-headers-amd64. Then sudo reboot. Ctrl+Alt+F7 returns to the graphical screen. Recovery mode also works: in the boot menu that appears for a few seconds at start, choose Advanced options, then the entry ending in (recovery mode). After the passphrase it gives a root prompt without asking for a password; leave out sudo there.
nvidia-smi says it couldn’t communicate with the NVIDIA driverThe module is not loadeddkms status should list the NVIDIA module as installed for your kernel. Check that Secure Boot is off (6.3). Restart after any kernel update.
Notice “Mismatching nvidia kernel module loaded” after an updateThe new driver is installed, the old one is still runningRestart.
CMake error “unsupported GNU version”CUDA host compiler not setrm -rf build, then configure again with the CMAKE_CUDA_HOST_COMPILER option (11.3).
CMake cannot find CUDAToolkit not installedInstall nvidia-cuda-toolkit (9.4).
Build fails in CUDA files with errors about the CUDA versionThis llama.cpp release needs a newer CUDA than Devuan shipsUse the Vulkan backend instead: bring in libvulkan-dev, glslc, spirv-headers and nvidia-vulkan-icd with a packages round (check llama.cpp’s build documentation for the current list), then configure with -DGGML_VULKAN=ON in place of the CUDA options. It is somewhat slower.
Build stops with Killed, or the box freezes while buildingOut of RAMBuild again with -j 4, or -j 2.
Build prints UI: no assets availableChat page files missingRedo the last step of 11.2.

16.5 Running models

SymptomLikely causeFix
Server log shows cudaMalloc failed: out of memoryModel plus context does not fit in VRAMLower --ctx-size in the launcher, or use a smaller quantization (12.1).
Very slow repliesLayers running on the CPUCheck offloaded N/N layers (13.1). Keep --n-gpu-layers 99.
Model loads but writes nonsensellama.cpp is too old for this modelUpdate llama.cpp (section 11).
Firefox says it cannot connect to 127.0.0.1:8080The server is not running, or not ready yetStart ~/bin/ai-server and wait for the listening on line (13.1).

16.6 Connected machine

apt mentions a cdrom or asks you to insert a disc. The installer did not set up a network mirror (4.2, step 5). Open the list of sources:

sudo nano /etc/apt/sources.list

Put a # at the start of the line that mentions cdrom, add these three lines at the end, then save and exit:

deb http://deb.devuan.org/merged excalibur main contrib non-free non-free-firmware
deb http://deb.devuan.org/merged excalibur-updates main contrib non-free non-free-firmware
deb http://deb.devuan.org/merged excalibur-security main contrib non-free non-free-firmware

Then run sudo apt update again. Expected: Get: lines and no line starting with E:.

Appendix A: Make your own GGUF

Advanced. Skip this on your first build, and come back once everything else works.

Instead of downloading a GGUF file that someone else converted and shrank, you download the model’s original files from the organisation that made it, convert them to GGUF yourself, and shrink (quantize) the result on the box. This removes the third-party quantizer from your chain of trust. You then trust the publisher’s original model, llama.cpp’s converter, the Python packages the converter needs, and the connected machine that runs them. There is no publisher checksum for a file you made yourself, so the phone check does not apply to it. The cost is a much larger download: about 110 GB of free disk on the connected machine for a 27B model, and an IN drive of 128 GB or more.

Your own quantization has no importance matrix (extra data some quantizers use to decide which numbers matter most), so at the same size it is slightly worse than the best published quants. If VRAM allows, use Q5_K_M instead of Q4_K_M to make up for it.

The converter is a Python program, and it needs Python packages, which pip, Python’s package installer, downloads. That is fine on the connected machine; only the finished GGUF crosses to the box, and rule 6 (5.1) still forbids carrying Python packages in. A virtual environment (venv) is a separate folder for those packages, so they do not mix with the rest of the system.

On the connected machine, in a disposable qube or a fresh user account, use the llama.cpp folder from 11.1 (download it again if this is a new disposable) and the same <tag> as the build on the box:

cd ~/airgap/llama.cpp && git checkout <tag>
python3 -m venv ~/airgap/venv
. ~/airgap/venv/bin/activate
pip install -r requirements/requirements-convert_hf_to_gguf.txt

If python3 -m venv complains that venv is not available, install it with sudo apt install python3-venv and run that line again.

Expected: pip downloads and installs packages for several minutes and ends with a line starting Successfully installed.

Download the original model (its .safetensors files, which hold the numbers, plus its configuration and tokenizer files):

hf download <org>/<model> --local-dir ~/airgap/hf/<model>

Expected: progress bars for each file, then the folder path.

Convert it to a full-precision GGUF in the crossing folder, and save its checksum:

mkdir -p ~/airgap/in/<date>
python convert_hf_to_gguf.py ~/airgap/hf/<model> --outtype bf16 --outfile ~/airgap/in/<date>/<model>-BF16.gguf
cd ~/airgap/in/<date> && sha256sum <model>-BF16.gguf > <model>-BF16.gguf.sha256

Expected: many lines starting with INFO:, ending with one that says the model was successfully exported to your output file. For a 27B model the file is about 54 GB.

Write the IN drive (5.3). On the box, receive it, go into the received folder, and check that the copy matches what the connected machine made:

cd ~/in/<stamp>
sha256sum -c <model>-BF16.gguf.sha256

Expected: <model>-BF16.gguf: OK. Move both files to /srv/models as in 12.4. Then quantize it:

cd /srv/models
~/src/llama.cpp/build/bin/llama-quantize <model>-BF16.gguf <model>-Q4_K_M.gguf Q4_K_M

Expected: one line per part of the model as it is converted, ending with lines that report the quantize time and the total time. Point the launcher at the new file (13.6), and delete the BF16 file if you need the space.

Appendix B: Transfer log

Keep this on the connected machine, or on paper next to the box. Write one line per file per crossing. On the box, ~/sent already holds a copy of everything that went out.

DateDriveDirectionFileSHA256 (first 16 characters)SourceChecked on the box
INconnected machine to box
OUTbox to connected machine

For example, a model crossing might read: 2026-11-01, IN, connected machine to box, <model>-Q4_K_M.gguf, the first 16 characters of its checksum, huggingface.co/<org>/<repo>, phone check OK.

Appendix C: Rules card

Print this and keep it next to the box.

IN   connected machine -> box. Box reads it with receive, read-only. Box never writes it.
OUT  box -> connected machine. Box writes it with send. Connected machine only reads it.
     Neither drive has a filesystem. Neither is ever mounted.

Allowed in:   Devuan package files (section 9), a llama.cpp release and its web
              interface archive, GGUF files, plain .txt files you have read.
Never in:     programs from elsewhere, pip or npm packages, .bin .pt .pth .ckpt files.
Allowed out:  package requests, plain text you have read.

Every file:   checked on the box (apt, or a phone check), one line in the log.
Never:        phones, wireless keyboards or mice, network cables. Not even once.

Appendix D: Qubes OS and Whonix variant

This appendix is not a second build. It is the set of changes you make if you want the same air-gapped AI workstation on Qubes OS with Whonix instead of bare-metal Devuan. Read the main guide first. Everything here assumes you already understand the transfer rules and the llama.cpp build from the main sections.

Not for a first build. Qubes OS is a security-focused operating system that runs everything in separate virtual machines. It has its own learning curve on top of everything in this guide, and the graphics card setup below is the most fragile part of anything described here. Build the main guide’s box first. If you want this variant later, use Qubes as your everyday system on another computer for a few weeks before you start.

Qubes words used in this appendix:

  • Qube: one isolated virtual machine, a computer inside the computer, with its own programs and files.
  • dom0: the administrator qube that controls all the others and the screen. It has no network. Anything typed in a dom0 terminal can affect the whole machine, so type carefully.
  • Template: a qube that holds installed software for other qubes. You install programs in the template, and the qubes based on it get them after they restart.
  • App qube: an ordinary qube based on a template. Only its home folder (and /usr/local and /rw) survives a restart; everything else comes fresh from the template each time.
  • Disposable: a qube created fresh for one task and deleted when you close it.
  • netvm: the qube that gives another qube its network connection. A qube whose netvm is set to none has no network at all.
  • Whonix: a pair of qubes that sends internet traffic through Tor, a network that hides where the traffic comes from.
  • qrexec, policy: qrexec is the controlled channel through which qubes ask each other for services, such as copying a file. Its rules are called the policy.
  • PCI passthrough: handing a piece of hardware, here the graphics card, directly to one qube.

D.1 Should you do this at all

For the strongest isolation, no. The bare-metal Devuan box with a physical air gap is still the more secure design, and it is simpler. A physical gap cannot be bridged by software. Qubes isolation is enforced by a hypervisor (Xen) and dom0, which are software, and GPU passthrough deliberately pokes a hole through part of that isolation.

What Qubes plus Whonix buys you, concretely:

  • One machine instead of two. The connected machine from the main guide becomes a disposable qube on the same box. No second computer.
  • Anonymous downloads. Template and model downloads can go over Tor through Whonix, so your IP is not tied to what you fetch. The physical-gap build gives you privacy on the box; it does nothing to hide what the connected machine downloads.
  • No USB shuffling. Files move between qubes with qvm-copy, not on a drive you plug and unplug.
  • Strong isolation between everything else. Your browsing, your email, your Bitcoin qube and the AI qube are all walled off from each other, not just the AI box from the world.

What it costs you:

  • A bigger trusted base. You now trust Xen, dom0, and the whole Qubes stack, not just one kernel.
  • GPU passthrough is fragile and not officially supported. It breaks between releases, depends on your exact board and firmware, and the procedure below is community knowledge, not a guarantee. Budget a weekend for it to not work the first time.
  • A possible memory limit on the GPU qube (D.5).
  • Soft isolation, not a gap. A Xen or dom0 compromise, or a passthrough side channel, can in principle cross between qubes. Rare, but not impossible the way a physical gap is.

Recommendation, by the axis that drives it:

  1. Security first, highest-threat use: stay on the bare-metal Devuan box (main guide). The physical gap is the stronger guarantee, and you avoid the hypervisor and passthrough attack surface entirely. Pick this if the point is maximum isolation.
  2. You also need anonymity for the downloads and want one machine: Qubes plus Whonix (this appendix). Pick this if hiding what you fetch, and folding the connected machine into the same box, is worth a larger trusted base and the passthrough headache.
  3. You want convenience over the gap: an ordinary hardened Linux box with the GPU. Not covered here. Only if the box holds nothing you would mind leaking.

The rest of this appendix is option 2.

D.2 The qube layout

QubeRoleNetwork
dom0The admin domain. Drives the display on the iGPU or a second GPU, never the 3090.None, ever (default).
sys-netHolds the network card.The only qube that touches the wire.
sys-firewallFilters between sys-net and the rest.Via sys-net.
sys-whonixWhonix gateway. Routes traffic over Tor.Via sys-firewall.
anon-whonix (disposable)Where you download templates and models over Tor. Replaces the connected machine.Via sys-whonix.
ai-templateA clone of the Debian template. Holds the NVIDIA driver, CUDA and an in-qube kernel.None; updates go through the UpdatesProxy (D.7).
ai-llmThe inference qube. GPU passed through. Builds llama.cpp and runs the model.None. This is the gap.
ai-build (optional)Compiles llama.cpp if the GPU qube has to run with very little RAM (D.5). No GPU.None.

D.3 Hardware changes

  • A CPU and board with VT-x and VT-d (IOMMU), with IOMMU enabled in firmware. Qubes needs it; passthrough doubly so.
  • A way for dom0 to drive a display that is not the 3090: a CPU with integrated graphics, or a second cheap GPU. The 3090 is handed to the AI qube, so dom0 cannot use it.
  • The 3090 should sit in an IOMMU group by itself, or in a group you can pass whole. Server and workstation boards are better at this than cheap consumer ones.
  • More system RAM than the main build. Every running qube takes RAM. 32 GB is a sensible floor, 64 GB comfortable.
  • Everything else from section 3 of the main guide stands.

D.4 What changes, section by section

Main guideOn Qubes plus Whonix
4. The connected machineA disposable Whonix qube. Downloads go over Tor.
5. Transfer drives and rulesqvm-copy between qubes. No physical media, so no receive or send. The one-way discipline becomes: files only ever move into ai-llm, never out to a networked qube.
6. Firmware settingsSame, plus enable VT-d and IOMMU. Leave the 3090 alone; dom0 drives the iGPU or second GPU.
7. Install DevuanInstall Qubes OS 4.3 instead. Full-disk encryption is set during install, so the LUKS steps are handled for you.
8. Cut the network for goodThe AI qube has its netvm set to none. That is its air gap. dom0 has no network by default.
9. Offline package updatesTemplate updates go over Tor through sys-whonix (D.7), or you keep them fully offline by copying .deb files into the template with qvm-copy.
10. NVIDIA driver and CUDAInstalled in the AI template, plus GPU passthrough and an in-qube kernel (D.6). For the optional power limit (10.1), put the two nvidia-smi lines in /rw/config/rc.local inside ai-llm, not in /etc/rc.local.
11. Build llama.cppBuilt in ai-llm, or in ai-build if ai-llm has very little RAM (D.6 step 7).
12 to 13. Models and runThe same llama.cpp commands, inside ai-llm. Models arrive by qvm-copy and live in ~/models, not /srv/models, because only the home folder survives a restart of an app qube.

D.5 Memory for the GPU qube

On older Qubes releases, a qube with a graphics card passed through often failed if it was given more than about 3.5 GB of RAM: the card did not start, or memory was corrupted, because of where the card’s memory-mapped I/O window lands below the 4 GB line. The fix was an unofficial dom0 patch, which is not part of stock Qubes. Some Qubes 4.3 users report larger amounts working with memory balancing turned off, but this depends on the board and is not guaranteed.

So:

  1. Try 8 GB first (recommended). That is enough to build llama.cpp inside the AI qube and to run it. D.6 step 4 sets it. If the card works (D.6 step 6), keep it.
  2. If the card fails to start or the qube crashes, fall back to about 3 GB. Your model sits in VRAM, not system RAM, so about 3 GB is usually enough to run pure-GPU inference where every layer is offloaded, which is why the main guide’s --n-gpu-layers 99 matters even more here. It is not enough to build llama.cpp, so you then build it in a separate ai-build qube (D.6 step 7).
  3. Do not patch dom0. The unofficial max-ram-below-4g patch would allow more RAM at the cost of modifying dom0, the one component you least want to change.

D.6 Passing the GPU to the AI qube

Honestly: this is the part that breaks. It is community-supported, version-specific, and depends on your board. Treat the commands as the shape of the procedure, and keep the Qubes community GPU-passthrough guide open for your exact release and card.

Steps, in a dom0 terminal unless stated. To open one, press Alt+F3, type terminal and choose Xfce Terminal. A dom0 window’s title does not start with a qube name in square brackets; that is how you know it is dom0.

First create the AI template and the AI qube. The first command copies the standard Debian template into a new template for the AI software, the second creates the AI qube from it, and the third removes the AI qube’s network:

qvm-clone debian-13-xfce ai-template
qvm-create --class AppVM --template ai-template --label red ai-llm
qvm-prefs ai-llm netvm ''

Expected: the clone takes a minute or two, then no output. If qvm-clone says the template does not exist, run qvm-template list --installed and use the Debian template’s exact name.

  1. Find the card’s addresses. You need two kinds of address for the card, and Qubes 4.3 writes them differently, so read both from the screen rather than converting one into the other. First:
lspci | grep -i nvidia

Expected: two lines, for example:

0a:00.0 VGA compatible controller: NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)
0a:00.1 Audio device: NVIDIA Corporation GA102 High Definition Audio Controller (rev a1)

The start of each line, here 0a:00.0 and 0a:00.1, is a PCI address: one for the graphics part of the card and one for its audio part. Yours may differ. Note both; steps 2 and 3 use them. Then:

qvm-pci

Expected: a list of devices, each with an ID in the first column and a description. Find the two lines with the same NVIDIA descriptions, and note their IDs exactly as printed. On Qubes 4.3 they can look like dom0:00_01.1-00_00.0 and dom0:00_01.1-00_00.1. Step 5 uses them.

  1. Hide the card from dom0, so dom0’s own driver does not grab it at boot. Open the boot settings:
sudo nano /etc/default/grub

Find the line starting GRUB_CMDLINE_LINUX=. Inside its quotes, after what is already there, add a space and this, using your two PCI addresses from step 1:

rd.qubes.hide_pci=0a:00.0,0a:00.1

Save and exit, then regenerate the boot configuration:

sudo grub2-mkconfig -o /boot/grub2/grub.cfg

Expected: a few Found ... lines and no errors. On Qubes 4.2 and later, this one file serves both older and UEFI systems, so do not write the configuration anywhere else. Restart the computer.

  1. Confirm dom0 is not using the card. Use your graphics PCI address:
sudo lspci -nnk -s 0a:00.0

Expected: a line reading Kernel driver in use: pciback. If it still says nvidia or nouveau, the hide did not take; recheck step 2.

  1. Give the AI qube a kernel of its own, and its memory. A passed-through GPU needs the qube to boot its own kernel in HVM mode, not the one Qubes provides. In dom0:
qvm-prefs ai-llm virt_mode hvm
qvm-prefs ai-llm kernel ''
qvm-prefs ai-llm maxmem 0
qvm-prefs ai-llm memory 8000

kernel '' means use the kernel inside the qube. maxmem 0 turns off memory balancing, which passthrough needs. memory 8000 gives it about 8 GB (D.5). Expected: no output from any of these.

In the AI template, install a real kernel and the boot support, so the qube has something to boot. Open a terminal in ai-template from the Qubes app menu, where it is listed under Templates, and run:

sudo apt-get install linux-image-amd64 linux-headers-amd64 grub2 qubes-kernel-vm-support

Expected: apt-get installs the kernel and grub. If a dialog asks where to install GRUB, select /dev/xvda with the Space bar, then press Enter. If no dialog appears, run sudo grub-install /dev/xvda afterwards. An error from grub-probe that mentions /dev/mapper/dmroot can be ignored.

Then, in the same template terminal, add the non-free section that the NVIDIA driver lives in, and install the driver, CUDA and build tools, the same list as 9.4:

sudo sed -i 's/contrib non-free-firmware/contrib non-free non-free-firmware/' /etc/apt/sources.list
grep non-free /etc/apt/sources.list
sudo apt-get update
sudo apt-get install linux-headers-amd64 nvidia-kernel-dkms nvidia-driver nvidia-smi nvidia-cuda-toolkit nvidia-cuda-toolkit-gcc build-essential cmake git firefox-esr qrencode util-linux-extra

Expected: sed prints nothing. grep prints the archive lines, each now containing contrib non-free non-free-firmware; if it prints nothing, the template’s sources look different, so add non-free by hand with sudo nano /etc/apt/sources.list. The install then behaves as section 10 describes. When it finishes, shut the template down from dom0 with qvm-shutdown --wait ai-template, so that ai-llm picks up the new software.

  1. Assign the card. On Qubes 4.3 this uses the device-assignment API. Assign both parts of the card to the same qube, using the IDs exactly as qvm-pci printed them in step 1:
qvm-pci assign --required ai-llm <graphics ID>
qvm-pci assign --required ai-llm <audio ID>

--required means the qube will not start unless the card is present. Expected: no output. You can do the same in the qube’s Settings, Devices tab, which is the easier path on 4.3.

If the qube later fails to start with a PCI reset error, remove the assignment and add it again with an option, trying one option at a time before combining them:

qvm-pci unassign ai-llm <graphics ID>
qvm-pci assign --required ai-llm <graphics ID> -o no-strict-reset=true

and, only if it is still stuck, -o permissive=true. Both widen the attack surface, so use the fewest that work. On 4.3 the nomodeset boot option is removed automatically when a GPU is attached, so you do not manage that yourself.

  1. Start the qube and check the driver sees the card. Open a terminal in ai-llm from the Qubes app menu; that starts the qube. In it:
nvidia-smi

Expected: the table from section 10 of the main guide, listing the RTX 3090 and about 24576 MiB. If it reports no device, the card did not pass through; see D.10. If the qube will not start, or crashes, with 8 GB, lower its memory in dom0 with qvm-prefs ai-llm memory 3000 and try again (D.5).

  1. Build llama.cpp. Bring the llama.cpp bundle and chat page archive in with qvm-copy (D.7), and check and build them as section 11 describes, with one difference: the files arrive in ~/QubesIncoming/<source-qube>/ instead of ~/in/<stamp>. The phone checks still apply.
    • If ai-llm has 8 GB, do all of this inside ai-llm.
    • If you had to fall back to about 3 GB, build in a separate ai-build qube instead. Create it in dom0:
qvm-create --class AppVM --template ai-template --label orange ai-build
qvm-prefs ai-build netvm ''
qvm-prefs ai-build maxmem 12000

Expected: no output. Build there, then, inside ai-build, send the finished programs to ai-llm:

qvm-copy ~/src/llama.cpp/build/bin

Choose ai-llm in the dialog. Inside ai-llm, move them to where the launcher (13.1) expects them:

mkdir -p ~/src/llama.cpp/build
mv ~/QubesIncoming/ai-build/bin ~/src/llama.cpp/build/

Expected: no output. The binaries are built with an $ORIGIN rpath in the main guide, so they run from their new folder.

Then bring in a model and run it as in sections 12 and 13, inside ai-llm, with one change: keep models in your home folder, for example ~/models (create it with mkdir -p ~/models), and write the launcher’s model line as MODEL=$HOME/models/<file>.gguf. Anything outside the home folder disappears when the qube restarts.

Once the card has been assigned to ai-llm and that qube has run, the card does not return to dom0 on its own. That is intended. Do not try to reattach a GPU to dom0.

D.7 Updates and downloads

Two paths cross the boundary now, and they are not the same.

Downloads (models, llama.cpp, template installs): over Tor, into a disposable. Fetch in a Whonix disposable qube, which routes through sys-whonix over Tor. Then move the file into ai-llm. In the disposable, run qvm-copy on the file:

qvm-copy FILENAME

Expected: a dialog asking which qube to copy to; choose ai-llm. The file lands in ~/QubesIncoming/<source-qube>/ inside ai-llm. From there, hash it and check it exactly as section 12 of the main guide says. The phone check still applies: the publisher’s own checksum, verified on a device that is not this computer.

The one-way rule from section 5 carries over and matters more here, because the drive is now a command: only ever qvm-copy into ai-llm. Never copy from ai-llm to a qube that has a network. If you must get output out of ai-llm, such as a benchmark log you have read, treat it with the same suspicion as the OUT drive in the main guide.

Template updates: over Tor, or fully offline. To let the AI template update over Tor, install Whonix first. Check the current template version rather than assuming it:

qvm-template list --available | grep whonix

Expected: lines for the current whonix-gateway and whonix-workstation, each with a version number. Install those exact names, then build the service qubes (replace the version shown):

qvm-template install --enablerepo=qubes-templates-community whonix-gateway-VERSION whonix-workstation-VERSION
sudo qubesctl state.sls qvm.anon-whonix

Expected: the templates download through your normal update qube (over Tor only if you chose to update through Whonix when you installed Qubes), then sys-whonix and anon-whonix are created. To route all template updates through Tor from now on, apply the updates-via-whonix state:

sudo qubesctl state.sls qvm.updates-via-whonix

Expected: it writes a line like the following into /etc/qubes/policy.d/50-config-updates.policy, after which template updates go over Tor. This affects every template, not just the AI one.

qubes.UpdatesProxy  *  @type:TemplateVM  @default  allow target=sys-whonix

If instead you want the AI template to never touch the network at all, like the main guide, skip Whonix for it: fetch the .deb files in a disposable, qvm-copy them into the template, and apt-get install them there from the copied files. More work, stronger guarantee.

D.8 Lock the AI qube down

By default, qubes can ask each other to copy files, open files, and run programs, and the shipped policy allows some of those to a disposable without even prompting. For the one qube whose whole job is to run untrusted model files, shut those doors.

Set its default disposable to none, so a compromised ai-llm cannot spawn a disposable to carry data out:

qvm-prefs ai-llm default_dispvm ''

Expected: no output.

Then create a policy file in dom0 with sudo nano /etc/qubes/policy.d/30-ai.policy. The 30 sorts before the 50 default files, so it wins. Type one blunt rule: ai-llm may not call any service in any other qube.

*  *  ai-llm  @anyvm  deny

This denies every qrexec call from ai-llm, including file-copy, open-in-VM, open-URL and run-command, to every target. Expected: after you save the file, calls from ai-llm are refused. Test it: from inside ai-llm, a qvm-copy should now fail. Copies into ai-llm from your download disposable still work, because the policy keys on the source, and ai-llm is never the source there.

Leave ai-llm with no netvm:

qvm-prefs ai-llm netvm ''

Expected: no output. This is the air gap, enforced by the hypervisor instead of by the absence of a cable.

D.9 Checks

Add these to the section 15 list when on Qubes:

CheckCommand (in dom0 unless noted)Expected
dom0 released the GPUsudo lspci -nnk -s <graphics PCI address>Kernel driver in use: pciback
Card reached the qubenvidia-smi in ai-llmRTX 3090, about 24576 MiB
AI qube has no networkqvm-prefs ai-llm netvmempty
AI qube cannot call outqvm-copy from inside ai-llmrefused by policy
No disposable escapeqvm-prefs ai-llm default_dispvmempty

D.10 Troubleshooting

SymptomLikely causeFix
nvidia-smi in the qube says no devicesThe card did not pass throughCheck pciback in dom0 (D.6 step 3). Check both parts of the card were assigned, with the IDs qvm-pci printed. Check the qube is HVM with kernel ''.
Qube will not start, PCI reset errorArbitrator too strict for this boardUnassign, then assign again with -o no-strict-reset=true, then if needed -o permissive=true (D.6 step 5).
GPU qube fails to start the card, hangs or corrupts memoryToo much RAM for this board’s passthroughLower memory to about 3000 (D.5).
Template cannot find nvidia packagesSources missing non-freeAdd non-free in /etc/apt/sources.list (D.6 step 4).
Build killed for lack of RAMNot enough RAM to compileBuild in an ai-build qube and qvm-copy the binaries (D.6 step 7).
Models or settings gone after restarting ai-llmThey were stored outside the home folderKeep models in ~/models and the power limit in /rw/config/rc.local (D.4, D.6).
dom0 display dead after hiding the carddom0 had no other GPUYou need an iGPU or second GPU for dom0 (D.3). Undo by removing rd.qubes.hide_pci at the grub edit screen.

Appendix E: Offline operation and internet access

E.1 It already runs with no internet

The model is a file. llama.cpp loads the weights from disk and generates everything locally, on the GPU. Nothing in normal operation needs a network. That is the whole point of the build, and it is why the main guide can cut the network for good in section 8 and lose no function. You can run the box for years, unplugged from everything, and it will answer exactly as well on day one thousand as on day one.

E.2 What offline costs you

The model knows only what was baked into its weights when it was trained, frozen at a cutoff date. Offline, that has three consequences, and you should design around them:

  • No live facts. It cannot tell you today’s price, today’s news, who holds an office now, or anything that happened after its training cutoff. It has no clock and no feed.
  • It will not say it does not know; it will invent. Asked past what it learned, a model produces a fluent, confident, wrong answer. Offline you have no second source to catch it. Treat every factual claim as unverified.
  • No tools. It cannot open a URL you paste, fetch a file, or run a search, because none of those reach anywhere.

Two ways to blunt this without breaking the gap:

  1. Carry a reference library in. Bring a local, offline copy of a reference collection across on the IN drive, look things up in it yourself, and paste what you need into the chat; llama.cpp cannot read it directly. Offline Wikipedia is the usual one: fetch a Wikipedia archive on the connected machine, carry it in, and browse it on the box. The reader, Kiwix (package kiwix), is in the Devuan repositories, so it installs through the normal offline-package process in section 9. The archive is a new kind of file under rule 5 (5.1), and like a model file it is read by a program on the box, so download it only from Kiwix’s official site, check its checksum against the one published there with a phone check, and add it to your copy of the rules card (Appendix C). Now you have facts to check the model against, on the same box, still with no network.
  2. Feed it the text yourself. When you have a document you want it to use, save it as a plain .txt file with a plain name (5.3), read it through, bring it in on the IN drive, open it on the box with less, and paste from there into the prompt. The model reasons over what is in front of it perfectly well; it just cannot go and get it.

E.3 What if I want it to check a site online

Here is the hard part, stated plainly: you cannot give this box live web access and keep it air-gapped. They are opposites. The moment the box can reach the internet, every protection in this guide is void. A poisoned model file, or a tampered llama.cpp, could then send anything on the box out to anywhere, and the whole design exists to stop exactly that. Letting the AI check a site is not a setting you toggle; it is giving up the gap.

So the real question is which machine does the reaching. Your options, best first:

  1. Keep the box air-gapped; do the lookup on the connected machine and carry the text in (recommended). When you want the model to work with a web page, open the page on the ordinary connected machine (section 4, or a Whonix disposable on the Qubes build), save its text as a plain .txt file with a plain name, read it through, carry it in on the IN drive, open it on the box with less, and paste it in. You get the model reading that page with zero packets leaving the box. You already have this pipeline; this is just using it for web pages instead of software.
  2. Run a second, ordinary machine for anything that needs to be online. Keep this box for everything sensitive, and use a normal networked computer, with a normal model that can browse, when you genuinely need live lookups. Clean separation, two machines. Pick this if you do online lookups often enough that carrying text in by hand is a chore.
  3. Drop the air gap on this box (not recommended). Give it a network and let the model fetch pages directly. This turns a deliberately air-gapped workstation into an ordinary one. You lose every guarantee the gap gave you, and a bad model file can now phone home. Only if the box holds nothing you would mind leaking, in which case you did not need this guide.

The recommendation, by the axis that drives it: security. Option 1 keeps the gap intact and still answers the real need, so it wins. The inconvenience of carrying a page in by hand is the price of the gap, and it is small. If that price is too high for how you actually work, that is a sign you want option 2, a separate online machine, not a hole in this one.

Amy Eskridge investigation

TLDR: Amy Eskridge a 34-year-old researcher working on anti-gravity, was murdered. Fourteen other dead or missing US scientists (plus two family members of one), were most likely one coordinated operation, or a small number of overlapping operations targeting the same classified US technology stack. Probability that the cluster is driven by a coordinated campaign of some form: very high.

If these scientists had quietly published all their research, and made it all public, would they still be alive today? If you want to create a knowledge base like this one and post your research online, and avoid getting suicided, see the next post or the post on Publishing a book.

Amy Eskridge

Amy earned a B.S. in Chemistry and Biology from the University of Alabama in Huntsville, followed by a Ph.D. in Materials Science from the same institution.1
Her father Richard Eskridge, a retired NASA engineer specializing in plasma physics and fusion, co-founded The Institute for Exotic Science and HoloChron LLC with her and served as Chief Technology Officer.1 2

In December 2018 she and her father gave a public talk at the Huntsville Alabama L5 Society (HAL5) titled “A Historical Perspective on Anti-Gravity Technology.” The slides and program are still online. The talk walked through antigravity devices from Thomas Townsend Brown’s Gravitator in the 1920s to the EM Drive, and touched on what she called “black projects” developing triangular antigravity craft like the TR-3B.2 3

In 2020 on a podcast she stated she was preparing to publish novel foundational work on antigravity but was waiting on NASA approval. In that same period she described the campaign against her escalating:4

I need to publish soon because it’s escalating. It’s getting more and more aggressive. This has been going on for like four or five years, and over the past 12 months, it’s been escalating, like more aggressive, more invasive digging through my underwear drawer and sexual threats.

On her reasoning for making her work public rather than private:5

She hired private security because she believed she was being physically and electronically surveilled and threatened. She reported that her security contractor, described by her as the former Chief Information Officer for Northrop Grumman with 35 years in Navy counter-espionage, told her he had heard the same pattern from other women in classified research roles.6

She told interviewers she had been struck by a directed energy weapon, leaving burn marks across her body, and described being nearly hit by a bus in what she treated as an attempt.7 8

In one recorded segment she describes active targeting in real time while typing, and the relief response when she and whoever she was with physically blocked the suspected surveillance path:9

My hands have been burned to hell and back as I’ve been typing. Because you can beam me through there or through there. And you can get a 3D image of what I’m typing. Because this computer doesn’t have a Wi-Fi card. You can’t hack it. But you can maybe get a 3D image of me typing. So like we just did that. And did that. As an act of pure desperation. And guess what? I feel better immediately I feel better immediately like my hands still burn because there’s damage my hands still burn because there’s damage but but it’s like my body relaxed when we barricaded and it was like oh like relief like immediately.

Two things in that passage carry independent weight. First, the technical specificity: through-wall radar imaging is real and deployed, microwave-band directed energy weapons are documented in the Active Denial System and related programs, and the distinction she draws (“you can’t hack it” but “you can maybe get a 3D image”) shows she understood the threat model in technical terms rather than generic paranoia. Second, the somatic report of immediate relief upon physical barricading. Whether the underlying cause was a weapon or not, the fear was real enough that her body marked the difference when the line of sight was broken.

In text messages later leaked by allies she wrote, paraphrased from reporting: “If you see any report that I killed myself, I most definitely did not” and “I would never kill myself or anyone else.”7

Her posthumously released interview with Jeremy Rys and Mark Sokol on YouTube is the most complete single statement she left of her research and the threat environment around it.1 Mark Sokol, her former collaborator and partner, gave a fresh on-record interview to NTD on April 22, 2026, stating Amy had been “taking technology that was developed at NASA and commercializing it” and confirming there had been “attacks that happened” prior to her death.10 His testimony directly corroborates her own pre-death claims rather than qualifying them.

She died June 11, 2022, age 34, in Huntsville, Alabama. The ruling was self-inflicted gunshot wound. No autopsy was publicly reported. She was cremated shortly after. No coroner’s report, police report, or toxicology screen has been released publicly.11 12

The other fourteen: confirmed deaths and disappearances

Each of these is confirmed by multiple mainstream outlets including CNN, CBS, Fortune, Newsweek, Fox News, and the New York Post, and by the White House and FBI statements issued April 17 to 23, 2026.13 14 15 16 None of the mainstream media are honest, so take it for what it’s worth.

Nuno F.G. Loureiro, 47. Director of MIT’s Plasma Science and Fusion Center. Shot and killed at his home near Boston on December 15, 2025. The shooter, Claudio Neves Valente, attended the same Portuguese academic program as Loureiro from 1995 to 2000, carried out a mass shooting at Brown University two days earlier killing two students, and died by apparent suicide in a New Hampshire storage unit on December 18.17 The mainstream media read this as personal grievance.

Carl Grillmair, 67. Caltech astrophysicist working on NEOWISE and NEO Surveyor (the detection backbone for Blue Origin’s NEO Hunter planetary defense system). Shot dead on his porch in Llano, California on February 16, 2026. The shooter, Freddy Snyder, age 29, had been arrested months earlier for trespassing on Grillmair’s property while armed with a rifle. That is stalking behavior, not a random encounter.13 15 18

Michael David Hicks, 59. NASA Jet Propulsion Laboratory research scientist, worked on the DART asteroid deflection mission and Deep Space 1. Died July 2023 in Sunland, California. Cause of death not publicly specified.16

Frank Maiwald, 61. NASA JPL principal researcher in space research. Died in Los Angeles 2024.16

Monica Jacinto Reza, 60. Co-inventor of Mondaloy, a nickel-based superalloy that solved the oxygen-rich staged-combustion problem the Soviets had mastered and US engine makers could not replicate for decades. Patented with Dallis Hardwick in 2010 (the patents all expired by December 2012; from that point Mondaloy has existed only as trade secrets in human heads). Spent 30 years at Rocketdyne and successor Aerojet Rocketdyne as a Technical Fellow, the company’s highest technical rank, and Associate Fellow of the American Institute of Aeronautics and Astronautics. Mondaloy is used in approximately 12 components of the AR1 engine (preburner, turbine rotor, turbine housing, ducts, lines, hot gas manifold) and is the material standing between the United States and a gap in its national-security launch capability after Russia cut off RD-180 servicing in March 2022. Sometime between 2021 and 2024, after L3Harris acquired Aerojet Rocketdyne in July 2023, she moved quietly to NASA JPL under her family name Reza rather than the professional name Jacinto attached to her patents and publications. No press release. At JPL she was Director of Materials Processing. Vanished while hiking with a friend in Angeles National Forest on June 22, 2025. Last seen 30 feet behind her companion, smiling. Body never recovered despite extensive search.19 20 21 22 Her disappearance occurred 23 days after DRACO, the US nuclear thermal propulsion program, was canceled in the FY2026 budget request on May 30, 2025.23 The House Oversight Committee has formally flagged that Reza and McCasland worked together on an Air Force research program in the early 2000s on advanced materials for reusable space vehicles and weapons.24 After Hardwick’s death from breast cancer in 2014, Reza was one of two people in the world who knew how to make Mondaloy end-to-end.

Retired Air Force Major General William “Neil” McCasland, 68. Former commander of Air Force Research Laboratory at Wright-Patterson, where he oversaw a $4.4 billion S&T budget covering all Air Force science and technology programs. Hardwick’s Materials Directorate work and Reza’s Aerojet Rocketdyne contracts both flowed through his funding authority during the Mondaloy program scale-up. He was the funder linking the inventor side and the government qualification side of Mondaloy. Secondarily, Wright-Patterson is long rumored to house Roswell debris, and McCasland was named in the 2016 WikiLeaks Podesta emails in connection with UAP research, which is the angle that initially drew public attention to his case. Walked out of his Albuquerque home on February 27, 2026, leaving behind phone, prescription glasses, and wearables; took his hiking boots, wallet, and a .38 revolver.13 20

Steven Garcia, 48. Property custodian at the Kansas City National Security Campus, which manufactures non-nuclear components for American nuclear weapons. KCNSC operates joint additive-manufacturing facilities with Sandia for nickel-based superalloys in weapons components, the same material family Mondaloy belongs to.25 Missing since August 28, 2025, last seen leaving his Albuquerque home on foot carrying a handgun.16

Melissa Casias, 54. Los Alamos National Laboratory employee with security clearance. Missing since June 2025. Her phone was factory-reset, purse and keys left behind.15 26

Anthony Chavez, 79. Retired Los Alamos National Laboratory employee. Missing since May 2025, left home without belongings.26

Jason Thomas. Assistant director at Novartis with active Department of Defense contracts. Disappeared from Wakefield, Massachusetts December 12, 2025. Body recovered from Lake Quannapowitt March 17, 2026. Police reported no evidence of foul play, though that is a preliminary finding rather than a case closure.14

Matthew James Sullivan, 39. Former US Air Force intelligence officer. Died in 2024 before he could testify in a federal whistleblower case concerning UFOs. Rep. Eric Burlison has called the “suicide” ruling suspicious.13

Joshua LeBlanc, 29. NASA aerospace technologies electrical engineer at Marshall Space Flight Center in Huntsville. Team lead for NASA’s Space Nuclear Propulsion Instrumentation and Control Maturation program, then team lead on DRACO (Demonstration Rocket for Agile Cislunar Operation), a nuclear thermal propulsion engine designed to enable Mars missions. DRACO operates at extreme temperatures where nickel-based superalloys in the Mondaloy family are the only materials capable of surviving the hot-section environment. DRACO was canceled on May 30, 2025 in the FY2026 budget request, with all nuclear thermal and electric propulsion funding eliminated.23 LeBlanc died July 22, 2025, 53 days after the cancellation, in a fiery Tesla crash in rural Alabama near Florence. Body burned beyond recognition, required three days and the Alabama Department of Forensic Sciences to identify. Tesla Sentry Mode data showed the vehicle sat at Huntsville International Airport for four hours that morning, then drove west on rural backroads outside his known plans. Phone, wallet, and dog left at his apartment. Family reported him missing at 4:32 AM; he was found dead at 2:45 PM. His cousin Brittany Fox publicly stated the family feared he had been abducted. His case resurfaced in mainstream reporting April 22-23, 2026 as a cluster member.27 28 29

James “Tony” Moffatt, 60. Decorated Army veteran (retired Lt. Col., 21-year career), Georgia Tech MS in aerospace engineering, US Naval Test Pilot School graduate, payload and flight crew support specialist at NASA Johnson Space Center Astronaut Office contributing to 14 Space Shuttle ISS construction missions, founded Moffatt Systems Inc. aerospace consulting, principal research engineer at UAH where he worked on the Army’s Degraded Visual Environment Mitigation program and Next Generation Unmanned Aircraft Systems.30 31

Andrew Moffatt, 30. James Moffatt’s son. Research engineer and scientist at UAH’s Research and Engineering Support Center (RSESC).30 31

Both Moffatts died April 17, 2026 when James’s Mooney M20 single-engine aircraft crashed in a wooded area near Union County Airport in South Carolina on the return leg from Raleigh-Durham to Huntsville after a refueling stop. His wife Leasa (61) and other son William (28, information technology specialist) died in the same crash; they are not counted as researchers in the cluster but their deaths are part of the event. NTSB and FAA are investigating; cause has not been released; the investigation is expected to take approximately one year.30 31 32

All fourteen cases are confirmed real. With Amy, fifteen named cluster members (plus two non-researcher Moffatt family members killed in the same aircraft).

The unifying field

The fourteen plus Amy do not represent fifteen different unrelated research topics. They represent five interlocking subdivisions of one classified US technology stack.

Advanced propulsion and gravity modification: Eskridge (exotic propulsion, antigravity, gravity modification), Reza (nickel super-alloys for reusable rocket manufacturing), McCasland (oversaw classified space weapons programs), LeBlanc (NASA DRACO nuclear thermal propulsion, team lead on the engine designed to enable Mars missions), James Moffatt (NASA Johnson Space Center payload specialist on 14 ISS construction missions, UAH principal research engineer on defense aviation systems), Andrew Moffatt (UAH research engineer).

JPL planetary science and space defense: Hicks (DART asteroid deflection), Maiwald (JPL space research), Grillmair (NEOWISE and NEO Surveyor, the detection infrastructure that missile defense and NEO Hunter depend on), Reza again overlapping.

Fusion and plasma physics: Loureiro (MIT fusion director, magnetic reconnection expert). Amy’s father Richard’s specialty is also plasma physics and fusion, and Amy’s own exotic propulsion work overlapped with plasma approaches to gravity modification.

Nuclear weapons and propulsion infrastructure: Casias and Chavez (Los Alamos, where the US built its first nuclear weapons and continues maintenance and development), Garcia (Kansas City NSC, non-nuclear components of active US nuclear weapons), LeBlanc again overlapping (nuclear thermal propulsion).

UAP-adjacent classified programs: McCasland (Wright-Patterson, long rumored to house Roswell debris, named in Podesta emails on UAP), Sullivan (UFO whistleblower who died before testimony), Eskridge (exotic propulsion research adjacent to UAP propulsion theories).

The Mondaloy pattern

The single tightest documented professional link between cluster members runs through Mondaloy. Mondaloy is a nickel-based superalloy invented in the mid-1990s by Monica Jacinto (later Reza) at Rocketdyne and Dallis Hardwick at Rockwell Science Center. The name is a portmanteau: “Mon” from Monica, “Dal” from Dallis. Hardwick and Reza put their names into the material itself, which means their names are sitting inside the engines launching America’s national security satellites whether anyone acknowledges it or not.20 21

The strategic problem Mondaloy solved is specific and was longstanding. Oxygen-rich staged-combustion rocket engines are the most efficient propulsion architecture known to science. Russia mastered them in the 1960s. The US could not replicate them for decades, not because of physics or funding but because of metallurgy: the metals strong enough to survive high-pressure gaseous oxygen would catch fire, and the metals that did not burn were too weak to hold the pressure. Mondaloy did both. It allowed the US to build the AR1 engine to replace Russian-made RD-180 engines on United Launch Alliance rockets. Mondaloy is used in approximately 12 components of AR1 (preburner, turbine rotor, turbine housing, ducts, lines, hot gas manifold). Two variants exist: Mondaloy 100 and Mondaloy 200, optimized for different temperature and pressure ranges. It can be cast, forged, and 3D-printed.20 21 25

Mondaloy became operationally critical in March 2022, when Russia cut off RD-180 servicing in retaliation for Ukraine sanctions. From that date forward Mondaloy was the single material standing between the United States and a gap in its ability to launch national security satellites.

All three Mondaloy patents had expired by December 2012. From that point forward, Mondaloy has existed only as trade secrets inside human heads. Three people knew how to make it end-to-end:

Dallis Hardwick (co-inventor, ran government qualification at the Air Force Materials Directorate). Died Stage IV breast cancer at Hospice of Dayton, Ohio, in 2014. Natural death, documented by death certificate.

Monica Jacinto Reza (co-inventor, ran contractor production at Aerojet Rocketdyne for 30 years). Disappeared June 22, 2025.

William “Neil” McCasland (the AFRL commander who funded both the inventor side and the qualification side during program scale-up). Disappeared February 27, 2026.

Two of the three remaining living people who knew Mondaloy end-to-end after 2014 have vanished within an eight-month window. That window opens 23 days after the May 30, 2025 cancellation of DRACO, the US nuclear thermal propulsion program. Joshua LeBlanc, who led DRACO’s instrumentation and control work, died 53 days after the cancellation. DRACO operates in the extreme-temperature regime where Mondaloy-family nickel superalloys are required. Steven Garcia, a property custodian at the Kansas City National Security Campus where Sandia operates joint additive-manufacturing facilities for the same nickel superalloy family used in nuclear weapons components, vanished August 28, 2025.

Four of the five Mondaloy-adjacent disappearances (Reza, McCasland, Casias, Chavez, Garcia) occurred in the Albuquerque/Los Alamos/Kirtland nexus. The New Mexico geographic concentration the document already noted has a material-specific explanation: New Mexico is where the US manufactures and qualifies advanced nickel superalloys for both nuclear weapons and propulsion programs.

This is the single most concrete professional link between cluster members yet documented. It is not speculative. It is patent records, published interviews, contract structure, and corporate history.

These fields share contractors (Aerojet Rocketdyne, JPL, Los Alamos, Wright-Patterson, Kansas City NSC, Northrop Grumman), funding streams (DoD, DoE, NASA classified programs), and in some cases specific project histories. The Reza-McCasland professional link is documented by the House Oversight Committee. Amy herself explicitly traced her field back to Ning Li, the other famous Huntsville antigravity researcher who got a DoD grant in 2001 and then disappeared from public view before dying in 2021.33 34

Geographic concentration is tighter than random. The cluster now spans three distinct metro nodes, each tied to specific classified facilities. New Mexico (Los Alamos, Albuquerque, Kansas City NSC contractor in Albuquerque): Casias, Chavez, Garcia, McCasland. Los Angeles County (JPL, Caltech): Hicks, Maiwald, Reza, Grillmair. Huntsville Alabama (UAH, NASA Marshall Space Flight Center, Redstone Arsenal): Eskridge, LeBlanc, James Moffatt, Andrew Moffatt, plus the Ning Li precedent. That’s thirteen of fifteen cluster cases in three metros tied to specific US classified aerospace and nuclear facilities. Huntsville is the US rocket city and the historical home of the Von Braun team, but five cluster members in a single mid-sized research community is tighter than anywhere else in the cluster and not producible by random selection from the national 20,000-person DoE/NASA pool.

Timing shows acceleration and continuation after federal investigation was announced. One case in 2022 (Eskridge). One in 2023 (Hicks). Two in 2024 (Maiwald, Sullivan). Five in 2025 (LeBlanc July, Reza June, Casias June, Chavez May, Garcia August). Five in late 2025 to April 2026 (Loureiro December 15, Grillmair February 16, McCasland February 27, Thomas body found March 17, Moffatt family April 17). The April 17 crash occurred the same day Trump first publicly discussed the scientist investigation, and LeBlanc’s case resurfaced five days later on April 22-23. That the tempo has continued after federal scrutiny was announced is evidence of either brazenness, confidence in non-identification, or an operational cadence the operator is unwilling to interrupt. All three readings strengthen the coordinated-action hypothesis.

Why Amy’s case is surfacing now

Five converging causes, in order of causal weight.

Ross Coulthart’s October 2025 statement: Coulthart, senior investigations correspondent at NewsNation with credibility on UAP topics,35 dedicated a segment on his Reality Check podcast (episode aired October 19 to 21, 2025) to Amy. After reviewing photographs of her burns and her emails, he publicly said, “I spoke to Amy Eskridge. I don’t think she killed herself.”36 37 This broke the story out of UFO-community forums into mainstream UAP media.

The acceleration in winter 2025 to spring 2026: Loureiro was shot, December 15, 2025. Thomas body recovered, March 17, 2026. Grillmair shot, February 16, 2026. McCasland vanished, February 27, 2026. Four high-profile cases in three months forced the question of whether earlier cases belonged in the same conversation.

Rep. Eric Burlison (R-MO), House Oversight Committee member and chair of the Subcommittee on Economic Growth, Energy Policy, and Regulatory Affairs, went on Fox News and stated Amy “was likely murdered by a U.S. aerospace company with a directed energy weapon.”38 A sitting congressman making that claim on television is a discrete escalation event.

The Trump administration took it up. On April 17, 2026 Trump told reporters “I just left a meeting on that subject, so pretty serious stuff.”13 Press Secretary Karoline Leavitt confirmed joint White House and FBI review.15 FBI Director Kash Patel confirmed a formal investigation, explicitly framing it around “classified access” and “foreign actors.”16 39 The House Oversight Committee under Chairman James Comer sent letters April 20 to 21, 2026 to the FBI, DoE, DoD, and NASA demanding briefings by April 27.16

The independent evidentiary record was already on file. Franc Milburn, a retired British intelligence officer Amy hired while alive, completed an investigation and submitted to Congress in 2023 a finding that she had been targeted with directed energy weapons and murdered by a US aerospace company.38 40 Michael Shellenberger anonymously released Milburn’s 177-page document publicly in July 2023. That record is what Coulthart and Burlison are now reading from.

The trigger was that the cluster grew large enough to force federal attention, and the Trump administration’s posture on UAPs made prosecutorial willingness visible. Her 2022 evidence hasn’t changed. The institutional appetite to look at it did.

The father question

Richard Eskridge, Amy’s father, publicly stated on April 20, 2026 that there is nothing suspicious about Amy’s death and pushed back against the conspiracy framing.41

Richard is a retired NASA engineer specializing in plasma physics and fusion, Amy’s closest research collaborator, co-founder and CTO of The Institute for Exotic Science, publicly aligned with her antigravity research through at least the 2018 HAL5 talk they gave together.1 2 12 He is exactly the person who would know best whether she was the kind of person who would kill herself, and exactly the person most vulnerable to pressure, threats, or access agreements if someone wanted the suicide ruling to stand.

Four honest readings of his public denial:

Genuine belief. He examined the available evidence as a scientist and as her father and concluded suicide. Families who lose children to suicide often work very hard to protect the ruling from being relitigated; reopening the question is emotionally punishing even when warranted. This reading is plausible and does not require anyone to be lying. Families also accept the official ruling because it is the easier, nameable thing; the alternative (your daughter was murdered and the state does not care) is harder to live with. So his statement could be not dishonesty but simply exhausted self-protection.

Compromise. He was paid, threatened, or bound by a nondisclosure obligation from NASA or a contractor that makes any other position impossible for him to voice. His specialty (plasma physics and fusion) would give him standing access agreements that could be invoked. Family can betray. This is not anomalous.

Privileged knowledge. He knows that the suicide ruling is wrong, but he cannot or will not share it.

There’s not much to go off of to distinguish between these from outside. But Richard’s position, by itself, does not close the case against the combined weight of her own recordings, the leaked texts, the Milburn investigation, the Sokol corroboration, the physical evidence of burns, and the procedural anomalies. A single family member’s denial is not dispositive against a primary-source evidentiary record.

Other family statements similarly weighed in but not decisive: Julia Hicks, Michael David Hicks’s daughter, told CNN on April 21 she sees no connection between her father’s death and the broader cluster and that no agency has contacted her.42 Susan McCasland Wilkerson, Neil McCasland’s wife, wrote on Facebook in March 2026 that she does not believe her husband was abducted.43 These are data points against the maximalist “all fifteen were hits” reading but not against any individual case being foul play.

Why this is most likely one operation

Earlier versions of this document treated each case as individually suspicious and then asked whether they connected. That is the wrong null hypothesis given the evidence. Fifteen people in narrow interlocking sub-fields of classified US space propulsion, space defense, fusion, and nuclear weapons, clustered in three metro areas tied to specific facilities, with accelerating timing, documented professional links between at least two of them, and case tempo that has continued after federal investigation was publicly announced, is not a distribution that demands independence be presumed. The correct null hypothesis is “one cause, unless proven otherwise.”

Under that null, the cases that initially looked “conventional” do not break away as cleanly as early reporting suggested.

Loureiro and Valente. The initial read was personal grievance: Valente attended the same Portuguese academic program as Loureiro in the 1990s, shot up Brown University two days before killing Loureiro, and died by apparent suicide three days after. The pattern is also exactly what a targeted operation would look like if you wanted plausible deniability: recruit or manipulate an unstable person with a grudge, direct him at the target, let the personal-grievance narrative write itself, then the shooter conveniently dies before interrogation. I am not asserting Valente was an asset. I am asserting the “clean personal grievance” reading is weaker than the press implied, and that a targeted operation that eliminated the director of MIT’s fusion program at the exact moment the broader cluster was accelerating is a possibility the “mentally ill former classmate” framing does not rule out.

Grillmair and Snyder. I reclassified this already. Armed trespass on the victim’s property months before the shooting is stalking behavior. The “random carjacker” frame does not survive that fact.

Thomas. Found in a lake three months after disappearing; police report “no evidence of foul play” as a preliminary finding. He had active DoD contracts. Preliminary findings in cases of this kind frequently change when deeper investigation runs.

Garcia. Walked away armed, possibly self-harm, but his access to the Kansas City National Security Campus (non-nuclear components of US nuclear weapons) keeps him inside the cluster’s field regardless of what actually happened to him.

Six of eleven original cases have genuinely unexplained circumstances: Eskridge, Reza, Casias, Chavez, McCasland, Hicks and Maiwald. Adding Grillmair (reclassified) makes seven. Adding Sullivan makes eight. Adding LeBlanc (airport detour, abandoned phone/wallet/dog, body burned beyond recognition which destroys forensic evidence, family-stated abduction fear) makes nine. The Moffatt plane crash is weighted more tentatively: single-engine GA aircraft have a real background accident rate, the NTSB investigation is pending, and the whole-family mass-casualty profile is different from the individual-target pattern in the rest of the cluster. That said, James Moffatt’s career profile is a direct fit (14 Space Shuttle ISS missions, UAH research engineer on defense aviation), Andrew Moffatt was at UAH Research and Engineering Support Center, both were Huntsville-based, and the crash occurred the same day Trump first publicly discussed the broader investigation. Conservative count of suspicious cases is nine of fifteen; an honest read including Moffatt as plausible raises it to eleven of fifteen. The circumstances themselves are patterned: phones factory-reset or left behind, bodies not recovered or recovered burned beyond recognition, causes not released, witnesses in the right place but never quite in view at the right moment.

Weighting the alternatives honestly:

One coordinated operation: most likely, 65 to 75 percent. A single actor (most plausibly a foreign state intelligence service given the scope, resources, and operational tradecraft required, though a US private aerospace contractor cannot be ruled out for some cases) running a campaign against the US classified propulsion and nuclear technology stack. The Mondaloy pattern is the single tightest professional link between cluster members and the strongest evidence for coordination on at least the Reza-McCasland-LeBlanc-Garcia subset.

Multiple actors targeting the overlapping field: 20 to 25 percent. For example foreign intelligence hitting some targets and a US private contractor hitting others, with overlap because both care about the same technology. Still effectively one story; the driver is the tech stack.

Mix of targeted and unrelated cases: 5 to 10 percent.

Pure coincidence: under 2 percent.

Combined, the probability that the cluster is driven by a coordinated campaign or set of overlapping campaigns against the same US classified technology stack is roughly 85 to 94 percent. The probability that the fifteen-plus deaths and disappearances are a random coincidence of unrelated events is roughly 2 percent or less.

The most probable conclusion

On Amy. Murder, probability 75 to 85 percent. Suicide, below 15 percent. The primary-source evidentiary record is her own recorded voice describing real-time directed-energy targeting with technically accurate detail, photographed burns, leaked texts explicitly disclaiming suicide, Milburn’s independent professional investigation concluding murder, Sokol’s corroboration of attacks and motive (commercialization of NASA-developed technology), Burlison’s public statement that she was likely murdered, the Ning Li precedent in the same city and field, and the procedural anomalies of no autopsy, swift cremation, and no public investigative record. Her father’s denial and the official suicide ruling exist as counterweights but do not touch the primary-source record. The ruling was produced by a process that performed no autopsy. Richard’s denial is explicable under four readings not limited to “it was actually suicide.”

On the cluster. One coordinated operation or a small number of overlapping operations targeting the same US classified technology stack, probability 75 to 90 percent. The unifying field is advanced propulsion, space defense, fusion, nuclear weapons infrastructure, and UAP-adjacent classified programs, which share contractors, funding, and professional networks. The geographic concentration (New Mexico and Los Angeles County), timing acceleration, and documented professional links between specific victims (Reza and McCasland) are consistent with a campaign and inconsistent with coincidence.

On the perpetrator. Two threat models are on the table and not mutually exclusive. Patel has framed the FBI investigation around foreign actors and classified access.39 Milburn’s 2023 submission proposed a US private aerospace contractor protecting classified technology from public commercialization.38 Both models can be true on different cases in the cluster.

For the Mondaloy targets specifically (Reza, McCasland, LeBlanc, Garcia), Russia has the sharpest specific motive. Mondaloy enabled the AR1 engine to replace the Russian RD-180. Russia cut off RD-180 servicing in March 2022 over Ukraine sanctions, expecting that move to constrain US national-security launches. Mondaloy made that constraint partial rather than crippling. Eliminating the people who hold the trade-secret knowledge of how to manufacture Mondaloy at production scale would re-impose the constraint. Iran is named in CBS reporting and China is in the frame for the broader cluster, but Russia has the most direct and recent material grievance against the specific Mondaloy-adjacent personnel.14 The Milburn “US aerospace contractor” model fits less well for Mondaloy targets specifically because Aerojet Rocketdyne (and L3Harris after the July 2023 acquisition) would want to protect their employees who carry the trade-secret knowledge, not eliminate them. The contractor-protection model still fits Eskridge well (her work threatened to commercialize technology a contractor wanted classified) but it is a poor fit for Mondaloy. Both models can coexist across the cluster.

On the specific-motive question. One hypothesis circulating publicly, attributed to X user anthonyandrews, proposes that the trigger was a major fusion technology breakthrough that would destroy the fossil fuel industry, with “significant progress made recently, then silence.”44 This hypothesis has partial support and partial mismatch, and both sides are worth stating honestly.

The partial support is real. Fusion made documented major progress in the relevant window. Lawrence Livermore’s National Ignition Facility achieved fusion ignition in December 2022, then 120 percent energy gain in repeated 2024 experiments. Helion’s Polaris prototype hit 150 million degrees Celsius with deuterium-tritium fuel in January 2026 (announced February 13, 2026), becoming the first private fusion machine to do so, with a Microsoft commercial power-purchase agreement and a commercial plant under construction in Washington state. Commonwealth Fusion Systems, spun out of MIT’s Plasma Science and Fusion Center, announced a commercial facility in Chesterfield, Virginia. The DOE released its Fusion Commercialization Roadmap on October 16, 2025 targeting grid power by the mid-2030s. Total private fusion funding grew from $1.7 billion in 2020 to $15 billion by September 2025. Nuno Loureiro, shot December 15, 2025, was the director of MIT’s Plasma Science and Fusion Center, quoted that month in MIT Energy Initiative coverage of the commercialization pathway. He was not peripheral to the fusion industry. He was at its institutional center. If a fossil-fuel-protecting actor were to pick one name in US fusion research to eliminate in late 2025, it would plausibly be Loureiro. And he is the one who was killed.

The partial mismatch is also real. A fusion-protection motive does not explain the bulk of the cluster. Eskridge, Reza, LeBlanc, the Moffatts, McCasland, and Ning Li worked on propulsion and space systems, not fusion power generation. Casias, Chavez, and Garcia worked on nuclear weapons infrastructure, which is tangentially related to nuclear science but not to commercial fusion economics. Hicks, Maiwald, and Grillmair worked on JPL planetary and space-defense systems. Sullivan was UAP-adjacent. A fossil-fuel-driven fusion-suppression operation that killed fifteen people across five research fields would be expending enormous resources on cases that have nothing to do with fossil fuel economics. The simpler read is that the cluster has a broader motive than fusion specifically, and fusion is one sub-field within the broader target set.

So the honest treatment of the Andrews hypothesis: as a specific trigger-level explanation it fits Loureiro well and may explain the acceleration of the tempo in late 2025, but it does not scale to the full cluster. It may be one component of a larger motive architecture. The larger motive most likely involves multiple overlapping interests (fossil fuel incumbents, classified US propulsion contractors, foreign intelligence services) all pursuing different subsets of the same broader target population for different reasons. The cluster looks like what you would see if several competing actors were all moving against the same class of people at once, because that class holds knowledge multiple powerful interests want suppressed, acquired, or controlled.

On Amy’s prediction. She explicitly said she was going public because that was the strategy Ning Li had failed to use, and because Li had been disappeared into a classified program as a result. Amy’s prediction was lucid: going private would have meant quiet burial. She was disappeared anyway, by a different mechanism than Li. The strategy did not save her, but it did mean her recordings and texts survived her and are now driving the federal investigation three years later. In that narrow sense, she was right: someone noticed.

An adjacent death: David Wilcock

On April 20, 2026, four days after Trump publicly confirmed the scientist investigation and approximately 24 hours after his own final YouTube livestream, David Wilcock died of a self-inflicted gunshot wound outside Nederland in Boulder County, Colorado. He was 53.45 46

In that April 19 livestream he said:45 47

I’m excited to be here, you know, every day that I have on earth is a gift and a blessing, and I’m very grateful for that, because frankly, people are disappearing. Scientists are going missing. It’s a little bit scary.

He was a paranormal author and YouTuber with about 511,000 subscribers, not a scientist. He held no security clearance, had no professional ties to the facilities in the cluster, and did not have access to the classified US propulsion, fusion, or nuclear technology stack. For those reasons he does not belong inside the eleven-plus case count. The campaign profile that fits the cluster targets people with classified access to specific research. Wilcock fit the profile of a public commentator on the cluster, not a researcher inside it.

Four facts keep him worth noting in the record:

The timing. He named the cluster publicly and died approximately 24 hours later. That is a striking proximity even if the causal chain runs through his own psychology rather than through external action.

The manner. His death was witnessed by Boulder County Sheriff’s deputies. A 911 call came in at 10:44 AM reporting an “unknown problem”; the dispatcher believed the caller was in a mental health crisis. Deputies arrived, found Wilcock outside a residence holding a weapon, and within minutes he used it on himself.46 Police-witnessed self-harm is the hardest kind of death to read as staged, which is why he fits a different category than Amy.

The harassment pattern. A whistleblower has claimed Wilcock was the target of a sustained cyberstalking campaign that harmed him significantly in the period before his death.48 If substantiated, that fits a softer version of the pressure pattern Amy described: prolonged harassment as a pressure mechanism, with the endpoint reached by pushing the target to self-harm rather than by direct killing. The mechanism would still be external; the final act would not be. His best friend, in contact with him through April 19, said he appeared to be in good spirits, which sits in tension with the mental-health-crisis framing the 911 dispatcher assumed.49

The spiritual framework. Wilcock operated in explicit occult territory, including claims of telepathic contact with non-human intelligences and reincarnation of the psychic Edgar Cayce. Scripture is direct: “There shall not be found among you anyone… who practices divination… or a medium, or a spiritist, or one who consults the dead. For whoever does these things is an abomination to Yahweh” (Deuteronomy 18:10-12, LSB). That framework matters when weighing his statements and when considering what he may have been contending with psychologically and spiritually. A man operating in divination who experiences sustained distress is not straightforwardly explained by either “he was murdered” or “he was mentally ill.” Both natural pressure and spiritual oppression may be involved, and neither rules out the other.

Wilcock is in the record as adjacent. Whatever happened to him is context the investigation should note but not conflate with the classified-scientist cluster.

Context: the Livelsberger manifesto

On January 1, 2025, US Army Green Beret Matthew Alan Livelsberger, 37, a Special Forces Operations Master Sergeant with 19 years of service, five Bronze Stars, and active TS/SCI clearance, died outside the Trump International Hotel Las Vegas. He shot himself in the head inside a Tesla Cybertruck loaded with sixty pounds of pyrotechnics and twenty gallons of fuel; the vehicle exploded seconds later. Seven bystanders had minor injuries; the Cybertruck’s steel body channeled the blast upward and limited damage. Livelsberger was on approved leave from his posting with the 10th Special Forces Group at Camp Panzer Kaserne in Germany, where his role was remote and autonomous systems manager for military drone operations.50 51

His death is not a cluster case. It was self-inflicted, witnessed in effect by the physical sequence of the shot preceding the explosion, and he had documented PTSD and was receiving mental health treatment. What matters for the Eskridge investigation is not how he died but what he wrote the day before.

On December 31, 2024, Livelsberger emailed a manifesto to retired Army Chief Warrant Officer Sam Shoemate. The email was made public on the Shawn Ryan Show podcast on January 3, 2025. The FBI and Las Vegas police said there was “strong evidence” Livelsberger wrote it though authentication was not conclusively finalized in early reporting. Its core claims, verbatim and paraphrased:52 53 54

The 2024 East Coast mystery drone sightings were not conventional aircraft. They were “the operational use of gravitic propulsion systems powered aircraft” by China, launched from Atlantic submarines, similar in purpose to the 2023 Chinese spy balloon.

“Only we and China have this capability.”

“The most dangerous threat to national security that has ever existed.” “Unlimited payload capacity and can park it over the [White House] if they wanted. It’s checkmate.”

“You need to elevate this to the media so we avoid a world war because this is a mutually assured destruction situation.”

Three honest readings of the gravitic propulsion claim:

One, the claim is substantively accurate. The US and China have classified gravitic propulsion programs operational at some level; Livelsberger had TS/SCI with “UAP and USAP” (Unacknowledged Special Access Program) access and was describing what he knew. This is the reading that most directly supports the Eskridge investigation, because it would mean Amy was researching and preparing to publish in a field the US classifies at the highest levels, which gives a concrete motive for silencing her.

Two, the claim is introduced disinformation overlaid on Livelsberger’s genuine testimony. The same email contained verifiable, specific, and damning claims about a 2019 US airstrike in Nimruz Province, Afghanistan, that allegedly killed civilians including women and children and was covered up, claims that align with UN ground investigations and documented policy changes. A documented counterintelligence pattern is to introduce fantastical-seeming claims alongside real whistleblower testimony to discredit the whistleblower (independent analysis by security blogger flyingpenguin develops this reading in detail).55 Under this reading, gravitic propulsion was chosen as the fantastical overlay precisely because the subject is strategically salient enough to serve as a discrediting vector.

Three, somewhere in between. Livelsberger had partial access to real classified discussions of exotic propulsion, mixed it with speculation, and his PTSD or TBI contributed to the narrative drift.

The key point is that all three readings strengthen the argument that Amy’s work was in a field the US treats as extraordinarily sensitive. If gravitic propulsion is real, she was a target. If it is disinformation, the disinformation architecture chose it as plausible cover, which tells you it is strategically salient enough to be weaponized in counterintelligence narratives, which in turn means a researcher working publicly on it is genuinely exposed. The hypothesis that Amy was killed for her research survives any of the three readings.

A note on the cluster’s composition in light of this. The framing “most of the scientists worked on anti-gravity propulsion” circulates publicly and is not quite accurate. The accurate version: a meaningful subset worked on exotic propulsion including gravity modification (Eskridge, Ning Li historically, and arguably Reza and McCasland via adjacent programs), and others worked in classified fields that share research infrastructure with gravity modification (fusion and plasma physics, nuclear thermal propulsion like LeBlanc’s DRACO, space defense and surveillance). The unifying variable is classified access to the US aerospace and nuclear technology stack, not any single research topic. Livelsberger’s manifesto is evidence for the strategic salience of one specific sub-field within that stack, not evidence that all fifteen cluster members worked on that sub-field.

Conclusion

Some will say do not research nickel. Do not research nuclear thermal propulsion. Do not look up who ran DRACO’s instrumentation. Do not look up who built Mondaloy. If you value your life, whatever you do, do not look up what they have in common. The fact that this document exists, that the SpaceNews 2017 interview with Monica Jacinto is still online, that the patents are public record, that Hardwick’s death certificate is in the Ohio vital records system, that the AFRL budget structure under McCasland is documented, that DRACO’s May 30, 2025 cancellation is in the FY2026 budget request, and that LeBlanc’s role is on his LinkedIn, means the answer to “what they have in common” is not actually hidden. It is sitting in plain sight, scattered across mainstream sources, waiting for anyone to assemble it. Don’t listen to the cowards. Study. Research. Verify. Publish. Publish anonymously if required. Use my guides if you don’t know how to publish. Build. Destroy evil. Let’s make the world a better place.

India’s government ordered GitHub to remove BitChat repositories within three hours, citing risks from its Bluetooth mesh networking that enables anonymous, serverless messaging during internet shutdowns amid NEET-related protests in Delhi. The open-source code was rapidly mirrored on Radicle, a decentralized protocol without central servers, allowing ongoing distribution and seeding by users despite the takedown effort. This event illustrates how decentralized infrastructure, like BitChat’s offline design and Radicle’s hosting, resists centralized control, highlighting tools built to bypass government network restrictions.

Footnotes


  1. Hack Liberty forum, “DR. AMY ESKRIDGE: The DEATH of a promising ANTI-GRAVITY researcher,” https://forum.hackliberty.org/t/dr-amy-eskridge-the-death-of-a-promising-anti-gravity-researcher/130/ ↩ ↩2 ↩3 ↩4 ↩5

  2. HAL5 program page, December 2018 talk by Amy Eskridge and Richard Eskridge, https://www.hal5.org/program-2018-12.shtml ↩ ↩2 ↩3

  3. HAL5 slide deck PDF, “A Historical Perspective on Anti-Gravity Technology,” https://www.hal5.org/PDF/HAL5-Dec2018-Talk-AntiGravity.pdf ↩

  4. UNILAD Tech, “Scientist working on disclosure of UFOs revealed aggressive threats made to her before tragic death,” April 17, 2026, https://www.uniladtech.com/science/space/scientist-disclosure-ufos-reveals-threats-made-before-death-904628-20260417 ↩

  5. StraightArrowNews via NewsNation, “Feds probing pattern of deaths, disappearances of UFO researchers,” April 2026, https://san.com/cc/feds-probing-pattern-of-deaths-disappearances-of-ufo-researchers/ ↩

  6. Washington Examiner, “What we know about the 11 scientists who died or went missing,” April 20, 2026, https://www.washingtonexaminer.com/news/crime/4535874/what-we-know-scientists-missing-died-murdered/ ↩ ↩2

  7. Thumars, “The Silenced Spark: Dr. Amy Eskridge’s Final Stand Against the Gravity of Secrets,” Medium, October 20, 2025. Polemical source; use for Coulthart quote attribution and for text-message paraphrase, cross-check factual claims against primary reporting. https://medium.com/@Thumars/the-silenced-spark-dr-amy-eskridges-final-stand-against-the-gravity-of-secrets-45754220d16f ↩ ↩2

  8. Battle4urBrain / RyanFar12516314 on X, summary of Milburn’s findings on DEW burn patterns, https://x.com/RyanFar12516314/status/2045445583805243630 ↩

  9. Primary audio transcript provided by the user from one of Amy Eskridge’s recorded segments, believed to be from her posthumously released Rys/Sokol YouTube interview or related recording. Exact source to be verified against published archive. ↩

  10. NewsOne / 93.9 WKYS aggregating NTD April 22, 2026 interview with Mark Sokol, https://newsone.com/6856977/what-happened-to-dead-or-missing-scientists-what-we-know/ ↩

  11. Fox News, “11th scientist death emerges in string of missing, dead officials with access to US secrets,” April 17, 2026, https://www.foxnews.com/politics/11th-scientist-death-emerges-string-missing-dead-officials-access-us-secrets ↩

  12. BroBible, “Scientist Who Warned Her Life Was In Danger The 11th Person With Ties To Secret US Research To Mysteriously Go Missing Or Die,” April 2026, https://brobible.com/sports/article/scientist-11th-person-secret-research-missing-die/ ↩ ↩2

  13. CNN, “At least 10 scientists tied to sensitive US research have died or disappeared in recent years, sparking federal investigation,” April 21, 2026, https://www.cnn.com/2026/04/21/us/deaths-disappearances-scientists-investigation ↩ ↩2 ↩3 ↩4 ↩5

  14. CBS News, “FBI investigating deaths and disappearances of staff at secretive government laboratories. Here’s what we know,” April 21, 2026, https://www.cbsnews.com/news/deaths-disappearances-scientists-staff-government-labs/ ↩ ↩2 ↩3

  15. Fortune, “Federal government launches broad probe into mysterious disappearances and deaths of top scientists,” April 19, 2026, https://fortune.com/2026/04/19/federal-government-investigation-disappearances-deaths-nuclear-space-scientists-energy-secretary-chris-wright-trump/ ↩ ↩2 ↩3 ↩4

  16. Newsweek, “FBI Investigating Missing and Dead Scientists: What We Know So Far,” April 20, 2026, https://www.newsweek.com/fbi-investigating-missing-dead-scientists-what-we-know-11852176 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  17. Nature, “MIT fusion-lab head shot dead: a horror ‘impossible to believe’,” December 17, 2025, https://www.nature.com/articles/d41586-025-04135-9 ↩

  18. Fox LA, “11 missing or dead scientists draw federal scrutiny,” April 19, 2026 (Freddy Snyder prior armed trespass detail), https://www.foxla.com/news/white-house-fbi-investigation-la-county-scientists-missing-reza ↩

  19. Fox LA, “11 missing or dead scientists draw federal scrutiny, including 4 tied to LA County,” April 19, 2026, https://www.foxla.com/news/white-house-fbi-investigation-la-county-scientists-missing-reza ↩

  20. SpaceNews, “What is Mondaloy and why should you care?” Debra Werner interview with Monica Jacinto, December 20, 2017 (re-surfaced January 23, 2023), https://spacenews.com/what-is-mondaloy-and-why-should-you-care/ ↩ ↩2 ↩3 ↩4

  21. We Got This Covered, “This super alloy could end American dependence on Russian rockets — now two officials involved in its development are missing,” March 19, 2026, https://wegotthiscovered.com/fyi/this-super-alloy-could-end-american-dependence-on-russian-rockets-now-two-officials-involved-in-its-development-are-missing/ ↩ ↩2 ↩3

  22. Daily Mail, “Disappearance of rocket scientist takes chilling turn after link to critical defense technology comes to light,” April 2026, https://www.dailymail.co.uk/sciencetech/article-15721979/nasa-scientist-disappearance-Monica-Jacinto-Reza-california.html ↩

  23. Wikipedia, “Demonstration Rocket for Agile Cislunar Operations,” DRACO cancellation in FY2026 budget request May 30, 2025, https://en.wikipedia.org/wiki/Demonstration_Rocket_for_Agile_Cislunar_Operations ↩ ↩2

  24. Fortune, “Something sinister could be happening: FBI looks into dead or missing nuclear and space defense scientists,” April 21, 2026, https://fortune.com/2026/04/21/scientists-disappear-die-nasa-space-blue-origin-spacex/ ↩

  25. The Mondaloy File open-source investigation aggregating SpaceNews 2017 interview, patent records, AR1 program structure, KCNSC/Sandia additive-manufacturing facility documentation, and the L3Harris July 2023 Aerojet Rocketdyne acquisition. Pseudonymous source; primary documents underlying the claims are independently verifiable. https://mondaloy.nowa.ca/ and parallel substack publication https://legionaus.substack.com/p/what-is-mondaloy-and-why-cant-you ↩ ↩2

  26. Washington Times, “11 scientists are dead or missing,” April 21, 2026, https://www.washingtontimes.com/news/2026/apr/21/us-scientists-killed-fbi-pentagon-investigate-11-deaths/ ↩ ↩2

  27. Fox News, “NASA nuclear engineer found dead in burned Tesla after vanishing from his Alabama home last year,” April 22, 2026, https://www.foxnews.com/us/nasa-nuclear-engineer-found-dead-burned-tesla-vanishing-alabama-home-last-year ↩

  28. Open Magazine, “Burned Tesla, Missing NASA Scientist: How Joshua LeBlanc’s Death Sparked an FBI Investigation,” April 23, 2026, https://openthemagazine.com/world/burned-tesla-missing-scientist-nasa-engineers-fiery-death-sparks-fbi-investigation ↩

  29. Primetimer citing KLFY and New York Post reporting on Brittany Fox family statements, April 23, 2026, https://www.primetimer.com/news/what-happened-to-joshua-leblanc-nasa-nuclear-engineer-s-alabama-death-draws-scrutiny-amid-missing-scientists-mystery ↩

  30. Fox News, “NASA aerospace engineer killed in South Carolina crash amid scientist deaths,” April 21, 2026, https://www.foxnews.com/us/aerospace-defense-engineer-military-veteran-killed-plane-crash-family ↩ ↩2 ↩3

  31. IBTimes UK, “NASA Scientist James Moffatt, Wife And Sons Die In Plane Crash Three Days Before David Wilcock’s Sudden Death,” April 23, 2026, https://www.ibtimes.co.uk/nasa-engineer-family-plane-crash-ufo-researcher-death-1793233 ↩ ↩2 ↩3

  32. WRGA News citing NTSB and Union County Sheriff’s Office, April 21, 2026, https://www.wrganews.com/2026/04/21/aerospace-defense-engineer-military-veteran-killed-in-plane-crash-with-family/ ↩

  33. Huntsville Business Journal, “Uncovering The Mystery Of Huntsville’s Brilliant Anti-gravity Scientist,” July 30, 2023, https://huntsvillebusinessjournal.com/news/2023/07/30/solving-the-mystery-of-huntsvilles-brilliant-scientist-disappearing/ ↩

  34. Wikipedia, “Ning Li (physicist),” https://en.wikipedia.org/wiki/Ning_Li_(physicist) ↩

  35. Ross Coulthart Wikipedia biography for credentials context, https://en.wikipedia.org/wiki/Ross_Coulthart ↩

  36. Reality Check with Ross Coulthart, Apple Podcasts, episode listing confirms “The suspicious death of Amy Eskridge” segment at 21:48, https://podcasts.apple.com/sg/podcast/reality-check-with-ross-coulthart/id1837783062 ↩

  37. Reality Check with Ross Coulthart, Podscan listing for same episode, https://podscan.fm/podcasts/reality-check-with-ross-coulthart ↩

  38. Rep. Eric Burlison statement aggregated at Targeted Justice Substack, “Amy Eskridge was likely murdered with a Directed Energy Weapon,” April 2026, https://targetedjustice.substack.com/p/amy-eskridge-was-likely-murdered ↩ ↩2 ↩3

  39. Newsweek, “Wave of Missing, Dead Scientists Could Be ‘Foreign Operation’: Congressman,” April 21, 2026 (Kash Patel “foreign actors” framing), https://www.newsweek.com/wave-of-missing-dead-scientists-could-be-foreign-operation-congressman-11853631 ↩ ↩2

  40. Franc Milburn’s 2023 congressional submission referenced in Hack Liberty writeup1 and in Washington Examiner reporting6. Document released publicly by Michael Shellenberger July 2023. ↩

  41. NewsNation, “Missing UFO scientists: Father of Amy Eskridge denies conspiracies about her death,” April 20, 2026, https://www.newsnationnow.com/space/ufo/father-dead-scientist-denies-suspicious/ ↩

  42. NewsOne citing CNN reporting April 21, 2026 (Julia Hicks statement), https://newsone.com/6856977/what-happened-to-dead-or-missing-scientists-what-we-know/ ↩ ↩2

  43. Same source as 42 (Susan McCasland Wilkerson Facebook post referenced in March 2026). ↩

  44. Attributed to X user anthonyandrews, circulating publicly. Supporting context on fusion progress from Helion/TechCrunch coverage February 13, 2026, https://techcrunch.com/2026/02/13/fusion-startup-helion-hits-blistering-temps-as-it-races-toward-2028-deadline/; DOE Fusion Commercialization Roadmap October 16, 2025, https://www.hoganlovells.com/en/publications/us-doe-releases-roadmap-for-fusion-energy-commercialization; Time, “Why the AI Industry Is Betting on Fusion Energy,” October 29, 2025, https://time.com/7328213/nuclear-fusion-energy-ai/; MIT Energy Initiative coverage quoting Loureiro, December 8, 2025, https://energy.mit.edu/news/funding-the-fusion-revolution/ ↩

  45. Yahoo/Globe, “Paranormal Writer and YouTuber David Wilcock Dead at 53 After Admitting He Had a ‘Really Rough Week’,” April 22, 2026, https://www.yahoo.com/entertainment/celebrity/articles/paranormal-writer-youtuber-david-wilcock-182341225.html ↩ ↩2

  46. TMZ, “David Wilcock, Paranormal Writer and YouTuber, Dead at 53,” April 22, 2026, https://www.tmz.com/2026/04/22/david-wilcock-dead/ ↩ ↩2

  47. The News (Pakistan), “David Wilcock’s final message: What he said just two days before his tragic death,” April 22, 2026, https://www.thenews.com.pk/latest/1399953-david-wilcocks-final-message-what-he-said-just-two-days-before-his-tragic-death ↩

  48. BollywoodShaadis, “Was David Wilcock Harassed By Cyberstalkers Who Destroyed UFO Expert’s Life? Whistleblower Claims,” April 23, 2026, https://www.bollywoodshaadis.com/articles/david-wilcock-cyberbullied-dead-78099 ↩

  49. BollywoodShaadis, “David Wilcock’s Best Friend Shocked Over UFO Expert’s Suicide,” April 22, 2026, https://www.bollywoodshaadis.com/articles/david-wilcocks-best-friend-shocked-over-ufo-experts-death-78088 ↩

  50. CNN, “Driver who exploded Tesla Cybertruck at Trump hotel in Las Vegas was an active-duty Army Green Beret,” January 2, 2025, https://www.cnn.com/2025/01/02/us/tesla-cybertruck-trump-hotel-wwk-hnk/index.html ↩

  51. NPR, “The soldier who died in Cybertruck explosion wrote it was intended as a ‘wakeup call’,” January 3, 2025, https://www.npr.org/2025/01/03/nx-s1-5247805/las-vegas-cybertruck-explosion-note ↩

  52. Newsweek, “Matthew Livelsberger Alleged Manifesto: Read Full Email Sent to Retired Soldier,” January 5, 2025, https://www.newsweek.com/matthew-livelsberger-alleged-manifesto-read-full-email-sent-retired-soldier-2009573 ↩

  53. Newsweek, “Matthew Livelsberger Warned People About World War III: Alleged Manifesto,” January 4, 2025, https://www.newsweek.com/matthew-livelsberger-warned-people-about-world-war-iii-alleged-manifesto-2009604 ↩

  54. SOFREP, “Green Beret Cybertruck Bomber Matt Livelsberger’s Story Takes a Bizarre Turn: Paranoia, War Crimes and Gravitic Propulsion Systems,” January 4, 2025, https://sofrep.com/news/green-beret-cybertruck-bomber-matt-livelsbergers-story-takes-a-bizarre-turn-paranoia-war-crimes-and-gravitic-propulsion-systems/ ↩

  55. flyingpenguin, “Gravitic Drones From China: Classic Counterintelligence Pattern in Livelsberger Case,” January 4, 2025 (independent counterintelligence-pattern analysis), https://www.flyingpenguin.com/?p=64204 ↩

Static Site Generators: Which one to pick in 2026

Which SSG to pick in 2026 for a blog, and which to pick for a book. The plan is to keep this post updated as things change.

TL;DR

For a blog, pick Zola or Hugo. Single binary, no Node, builds in milliseconds, and a site that will still build in 2036 from a binary and a tarball without a node_modules archaeology project. Astro is the consensus pick if you specifically need React islands or want a Cloudflare-backed managed path; most blogs don’t, and the single-binary route is the one that lets you stop thinking about the toolchain.

For a book or knowledge base, pick mdBook. Pick Quarto if you need PDF and ePub from the same source or there’s executable code in it. Pick Sphinx with MyST Markdown if you need deep cross-referencing, glossaries, indexes, or LaTeX output.

For a digital garden or an Obsidian vault you want to publish online with wikilinks, backlinks, and a graph view, pick Quartz. Nothing else on this list serves that use case natively.

Why an SSG, and not Substack, Medium, or Write Freely

When considering where to publish, there are three categories worth comparing: Hosted platforms, Self-hosted dynamic platforms, and Static site generators.

Hosted platforms (Substack, Medium, Ghost Pro, Bear Blog, and the long tail of similar services). You write in a polished editor; the platform handles hosting, discovery, analytics, comments, and – for Substack and Ghost Pro – paid subscriptions. The cost is that the platform owns your content: your URL, your subscriber-relationship format, the recommendation algorithm, the moderation policy, the aesthetic, and often a revenue cut. Lock-in varies. Substack’s newsletter export is workable, Medium’s is rougher, but the network effects of platform-native discovery don’t transfer no matter how clean the export. Substack is the strongest pick if a paid newsletter is the goal; Medium reaches Medium’s reader network at the cost of a paywall most casual readers don’t cross; Ghost Pro is closer to a portable self-hosted Ghost with managed ops; Bear Blog and similar minimalist services compete on simplicity, with smaller-scale in-platform discovery than Substack or Medium.

Self-hosted dynamic platforms (WordPress, Ghost self-hosted, Write Freely). You own the URL, the database, and the software. Write Freely is the most notable for readers of this doc: single Go binary, ActivityPub-native so Mastodon and Pleroma users can follow the blog directly; it has a distraction-free editor and runs on a Raspberry Pi. As of v0.16 (still pre-1.0), it powers around 550,000 blogs on the hosted Write.as service - the same software with the ops handled. The cost of self-hosting any platform in this category is real: server uptime, security patches, database and content backups, and an attack surface that grows with every plugin or theme.

Static site generators. Output is plain HTML on disk, no database, no server-side runtime, no CMS to patch. Deploy targets include GitHub Pages or Cloudflare Pages for free, a $5 VPS, a CDN, or a USB stick. Source is Markdown in Git, which (per the hundred-year section) outlasts every platform on this page. The honest costs: no built-in audience or recommendation algorithm, no comments without an external service, no subscriber-and-email management without something like Buttondown or Listmonk bolted on, content editing means opening a text file in an editor and running a build (mobile publishing is awkward), and any nontrivial design change means learning the SSG’s templating language.

Conclusion: Pick a hosted platform when audience growth and monetization dominate the decision and you accept the platform owning the relationship. Pick Write Freely or another self-hosted-dynamic platform when you want full ownership (and possibly federation) without a build step, and you’re willing to run a server. Pick an SSG when you want plain-text portability, minimum substrate, and free or near-free hosting, and you’re fine with a writer’s workflow that runs through Git and a terminal. This

Decision shortcut

For a blog, in order of preference:

  1. Zola or Hugo. Single binary, leave-you-alone community, site that builds in 2036.
  2. Eleventy if you want JS minimalism without framework weight.
  3. Jekyll only if you’re already on GitHub Pages and happy.
  4. Astro if you specifically need React islands or want Cloudflare-backed hosting.

For a knowledge base or documentation site, in order of preference:

  1. mdBook. Simplest, fastest, meant to live on the web but also has print capabilities.
  2. MkDocs + Material, built on Python, good for most project docs.
  3. Docusaurus only if you need versioned multilingual docs and you’re already React-heavy.
  4. Sphinx + MyST for large, reference-heavy, cross-linked docs.
  5. Astro + Starlight.

For a book, in order of preference:

  1. mdBook.
  2. Quarto if you need PDF/ePub from one source or there’s executable code in it.
  3. Sphinx + MyST (or Jupyter Book for an easier starting point built on the same foundation) if it’s academic, reference-heavy, or Python-adjacent.

For a digital garden or Obsidian vault, in order of preference:

  1. Quartz. The only tool on this list designed specifically for this.
  2. Astro with a community wikilinks plugin, if you need more design control and are willing to wire it up yourself.
  3. Obsidian Publish (the commercial first-party option), if you don’t want to self-host at all.

What counts as “widely used” in 2026

Rough current signals (GitHub stars, download trajectories, State of JS/HTML surveys):

  • Next.js has ~139k GitHub stars and the largest ecosystem of any SSG-capable framework.
  • Hugo has roughly 87,665 stars as of April 18, 2026, with a fast release cycle.
  • Astro has roughly 58,517 stars as of April 17, 2026.
  • Astro ranked #1 in State of JavaScript 2025 meta-framework satisfaction, 39 points ahead of Next.js.
  • MkDocs plus the Material theme is behind a large fraction of open-source project documentation portals.
  • Sphinx still has roughly 2.5x the live-site footprint of Quarto, despite Quarto’s rapid growth in the scientific and data communities.
  • Jekyll is the default on GitHub Pages and still runs countless personal sites, even though new projects rarely pick it.

Build reproducibility and the ten-year test

A real data point when comparing SSGs, is rarely named out loud: will the same source files build the same site in ten years with minimal intervention.

Single-binary SSGs win this decisively. A Hugo, Zola, or mdBook binary from 2026, kept on disk, will build your site identically in 2036. No interpreter to install, no package registry to query, no transitive dependencies to resolve. You can tar the binary with your content and walk away.

Python SSGs (MkDocs, Sphinx) sit in the middle. Python itself is stable enough that the stdlib-only parts work for decades, but plugins pull in the broader PyPI ecosystem, which has its own deprecation cycles. Pinning versions in requirements.txt and periodically rebuilding the environment is the realistic maintenance path.

Ruby SSGs (Jekyll) sit between Python and Node. Ruby itself is more stable than the npm ecosystem and less stable than Python’s stdlib floor. A Gemfile.lock pinned to a specific Ruby version, kept alongside the content, will usually resurrect a Jekyll site years later, but you’re maintaining a Bundler environment and a gem set, not just keeping a binary on disk. Better than Node, worse than Hugo or Zola.

Node-based SSGs (Astro, Next.js, Docusaurus, Eleventy, Quartz) sit at the hard end of this spectrum. JavaScript projects from 2020 routinely fail to build in 2026 without real dependency archaeology: deprecated packages, broken lockfiles, Node versions that no longer support your toolchain. This is not theoretical and it is not solvable by being careful. It’s the nature of the npm ecosystem. For long-lived sites this matters; for sites you rebuild weekly it doesn’t.

Quarto is a special case: the Quarto binary is single-distributable, but if your .qmd files execute Python or R code at render time, you also need to preserve those runtimes and their package versions. Pure-prose Quarto is reproducible; computational Quarto needs the same conda/renv discipline as any scientific project.

When the use case is genuinely open, a blog, a personal site, a reference doc, pick the single-binary tools. Hugo, Zola, and mdBook will build in 2036 from a binary and a tarball on disk. And as the next section argues, these happen to be the same tools whose communities will leave you alone. When two independent axes point at the same names, that’s a stronger signal than either axis produces on its own.

Content durability and the hundred-year horizon

The ten-year test above is a useful floor. The hundred-year question is a different one, and the answer changes the architecture. No SSG on this list has a credible shot at lasting a century, not Hugo, not Sphinx, not Quarto, not any of them. If the project’s horizon is actually that long (a reference encyclopedia, a denominational catechism, a dynasty-scale knowledge base), the question stops being “which tool” and becomes “which source format, which storage system, and what’s the migration plan when the current tool ages out.”

What’s proven durable at fifty years or more: plain UTF-8 text, LaTeX (frozen by Knuth on purpose), HTML (backwards-compatible by charter), PDF (ISO-standardized), Unix file conventions, and Git (twenty years and clearly winning as the Schelling point for the next several decades). What hasn’t proven durable: any specific SSG, any JavaScript framework, any particular version of a Ruby, Python, or Go tool, any cloud service, any company’s product. Gatsby was the frontier five years ago and is effectively dead. Jekyll still runs but is culturally static at sixteen. That’s the base rate.

The architecture for a hundred-year, many-thousand-page living work is three decisions, only one of which is about an SSG.

First, source format. Write in MyST-flavored CommonMark. MyST is CommonMark with academic-publishing extensions (roles, directives, cross-references, citations) that also parse cleanly as Pandoc-Markdown, so if the Python ecosystem fades, a Rust or OCaml or whatever-comes-next tool can still read the files. This is the decision that must be right.

Second, storage. Git. Content-addressable, distributed, already embedded in every serious workflow. The Linux kernel has around 80,000 files in git and scales fine; tens of thousands of Markdown files is not a storage problem.

Third, the build tool, which will be replaced several times over a century. For today, Sphinx with MyST is the strongest pick at large scale: an eighteen-year track record building CPython, NumPy, SciPy, pandas, and Django, all themselves decades-old projects. Crucially, Sphinx’s semantic cross-references (:ref: to a content-addressed label) survive file reorganization; Hugo’s text-based [link](path/to/page.md) model breaks silently when you rename pages, which becomes catastrophic past roughly 5,000 pages. If the budget exists, a custom Pandoc-based pipeline owned in-house is the only approach where no one can deprecate you. It’s what Wikipedia did with MediaWiki and what serious reference works have always done.

Two architectural commitments go with this. First: URL slugs, not file paths, as canonical identifiers. /v1/topic/subtopic/ style, stable forever, independent of where the source file lives. Second: the build layer is reviewed and possibly replaced every fifteen years, but the source files and their cross-reference labels are never touched destructively.

The Catholic Encyclopedia is the concrete precedent. It started as handwritten manuscripts, moved to typesetting, moved to Word, moved to XML, and is now partly on the web. The content outlived five toolchains because the editorial tradition treated each tool as temporary and each migration as a planned operation. That is the shape of the real answer. If you’re asking “which SSG lasts a hundred years”, you are asking the wrong question; those who building hundred-year knowledge systems pick durable source formats and replaced their tooling three times.

Community politics and the vibe test

Another axis most SSG comparisons pretend doesn’t exist: the cultural gravity of the community and corporate backer around each tool. The tools themselves don’t have politics. The communities, founders, and owners very much do, and that shapes what gets prioritized, what CoC fights you might get pulled into, and how the project behaves under pressure. Roughly sorted right to left:

Right-coded: Hugo, Zola, and mdBook. Go and Rust single-binary ethos, anti-dependency, corporate-pragmatic or institutional-narrow. Hugo and Zola are built on a “ship a binary, own your stack, no drama” value proposition that is about as right-coded as a build tool gets. mdBook sits with them by stack and purpose, though the Rust Foundation’s broader CoC posture bleeds in around the edges.

Center: Eleventy, MkDocs with Material, and Jekyll. Technocratic-neutral. Eleventy is run close to the chest by Zach Leatherman. Material for MkDocs is commercially sustainable through Squidfunk’s Insiders model and conspicuously absent from political fights. Jekyll is an old GitHub Pages default that just serves everyone equally.

Center-left: Astro, Quartz, and Next.js. Silicon Valley defaults. Astro is Cloudflare-owned, which means Cloudflare’s content-moderation and access-restriction posture will flow downstream eventually. Quartz is academic and digital-garden crowd, demographically young and academic-left. Next.js and Vercel are standard VC-backed SV progressive.

Left-coded: Docusaurus, Sphinx, and Quarto. Docusaurus is Meta corporate progressive, with Meta’s DEI apparatus attached. Sphinx isn’t politically loud as a tool, but it lives inside the Python community, which runs some of the most visible CoC and governance fights in open source. Quarto is Posit and academic data science, quieter than the others but demographically the most uniformly academic-progressive community on the list.

If you want software whose community will leave you alone, Hugo or Zola. If you want software embedded in an explicitly-progressive community, Sphinx, Quarto, or Docusaurus. Everyone else is mostly just trying to render Markdown.

Astro

An HTML-first framework that ships zero JavaScript by default and lets you mix in React, Vue, Svelte, or Solid components as “islands” only where you need them.

Built in: TypeScript and Node, with a Go-based compiler distributed as WASM.

License: MIT.

Good for: blogs, marketing sites, docs, anything content-heavy. The Starlight theme is a first-class docs setup that now powers docs for Cloudflare, Google, Microsoft, Netlify, OpenAI, and many others.

Bad at: being a no-dependency toolchain. You’re buying into Node, Vite, and npm. If your 10-year plan is “I want this site to build on a fresh Debian with zero fuss,” Astro is not that. Also: Cloudflare acquired Astro in January 2026, which is net positive for funding but introduces a real risk of Cloudflare-favoritism in future features.

Trajectory: up and to the right. 113 releases in 2025, Astro 6 landing in early 2026 with Server Islands and Live Content Collections. This is the SSG to bet on for a new blog today if you’re fine with Node.

Use if: you want the most popular, best-documented static-first option and you don’t mind npm.

Hugo

The fastest mainstream SSG. Single binary, Go-based, zero runtime dependencies, builds measured in milliseconds per page.

Built in: Go. Templates use Go’s text/template syntax, which most people hate on first contact and tolerate later.

License: Apache 2.0.

Good for: blogs, marketing sites, personal sites, any content where you want to write Markdown, run hugo, and deploy a directory. Handles very large sites (5000+ pages) without complaint.

Bad at: the template language. Go templates are unfriendly compared to Jinja, Liquid, or JSX. Customizing a theme deeply usually means fighting with range, with, and scope rules. This is literally why Zola exists - the Zola author built it because they hated Hugo’s template engine.

Trajectory: stable and maintained. Active release cadence through early 2026, with monthly releases typical. Not growing explosively, but not going anywhere either.

Use if: you want maximum build speed, a single binary, and you’re willing to tolerate Go templates (or you’ll use a stock theme and never touch them).

Next.js

Not really an SSG, but it can do static generation, and a lot of “static” sites in 2026 are actually Next.js sites with output: 'export' or generateStaticParams.

Built in: TypeScript, React, heavy Node toolchain.

License: MIT.

Good for: teams already in the React ecosystem who want the option to go dynamic later. Incremental Static Regeneration, API routes, edge rendering, and React Server Components all in one box.

Bad at: being a static site generator. It’s a full-stack framework that happens to support static export. The dependency surface is large, build times balloon on big sites, and you’re locked into Vercel’s direction unless you self-host (which is possible but nontrivial).

Trajectory: dominant in React-land, not really competing for the “I want a fast static blog” slot anymore - Astro ate that lunch.

Use if: your team already writes React all day and you want to keep the option to add a backend later.

Jekyll

The original. Ruby-based, Markdown + Liquid templates, and the default engine behind GitHub Pages.

Built in: Ruby.

License: MIT.

Good for: a zero-infrastructure personal blog where you push to gh-pages and it just works. If you already have a working Jekyll site, there’s rarely a good reason to migrate.

Bad at: build speed at scale, Ruby dependency management, and anything modern-JS-flavored. Plugins are limited on GitHub Pages’ whitelist.

Trajectory: maintained but culturally static. Nobody is starting new enterprise projects on Jekyll in 2026. It survives on inertia and GitHub Pages’ default.

Use if: you want the simplest possible “commit Markdown, get a blog” loop and you’re on GitHub Pages. Otherwise, pick something else.

Eleventy (11ty)

A minimalist JS SSG that’s deliberately small in scope. Supports Markdown, Nunjucks, Liquid, Handlebars, and straight JS for templates. No client-side framework, no build bundler by default.

Built in: Node.js.

License: MIT.

Good for: small to medium blogs and marketing sites where you want JS-ecosystem convenience without shipping React or Vue. Used by Netlify and the Chrome Developers site, among others.

Bad at: batteries. You assemble the site yourself. For docs or large structured content, you’ll end up writing more glue than with Astro or Hugo.

Trajectory: steady, beloved by a quiet crowd, not growing fast. A solid second-choice after Astro if you want something lighter.

Use if: you want JS SSG without framework weight, and you enjoy building things up from small pieces.

Zola

A single-binary Rust SSG in the Hugo family tree, using Tera templates (a Jinja2 clone). Sites build in under a second on average, including Sass compilation and syntax highlighting.

Built in: Rust.

License: MIT.

Good for: personal blogs, small-to-medium sites, documentation, landing pages. If you like Hugo’s architecture but hate Go templates, Zola is the answer.

Bad at: ecosystem size. Fewer themes than Hugo, fewer plugins than MkDocs, smaller community. If your site has unusual requirements, you’ll sometimes hit a wall where you’d just have found a plugin in Hugo or MkDocs. No native i18n workflow as mature as Sphinx’s gettext-based pipeline, though basic multilingual support exists.

Trajectory: slow, steady, healthy. Not exploding, not dying. Governance is controlled by a small maintainer team, which is a risk factor worth noting.

Use if: you want a fast, simple, single-binary SSG with a template language that doesn’t make you angry. This post is published on Zola.

Quartz

A static site generator built specifically for publishing Obsidian vaults and digital gardens. Out of the box it handles wikilinks, backlinks, transclusions, an interactive graph view, popover previews on link hover, and full-text search.

Built in: Node.js and TypeScript (v22+ required). Configuration is TypeScript, not YAML or TOML. You fork the Quartz repo, drop your Markdown into content/, edit quartz.config.ts, and push.

License: MIT.

Good for: anyone with an Obsidian vault who wants the published version to behave like the vault. Digital gardens, second-brain sites, interconnected note collections, lecture notes with heavy cross-linking. The publish: true frontmatter convention lets you keep private notes private and only surface the subset you want on the web, which is the workflow most Obsidian users actually want.

Bad at: everything that isn’t a digital garden. Traditional blog layouts fight the default theme. Documentation with formal structure (versioning, i18n, strict navigation) is not the target. Node dependency is heavy compared to Hugo or Zola. Wikilinks to notes you haven’t created yet render as broken links by default, which is a real UX problem for gardens-in-progress; there are community workarounds but no native fix.

Trajectory: actively maintained with a visible roadmap. One concrete risk worth flagging: Quartz v4 was a from-scratch rewrite that scrapped the v3 Hugo-based codebase and moved to Node.js. The project has already changed foundations once in its short life, and a v5 rewrite is not impossible. If you fork Quartz, you own the fork; upstream changes can be disruptive.

Use if: your source material lives in Obsidian and you want it on the web with the Obsidian experience intact. Almost nothing else on this list does this well.

Docusaurus

React-based docs framework from Meta. Excellent versioning and internationalization, clean default theme, MDX support.

Built in: React, Node.

License: MIT. Maintained by Meta.

Good for: software project docs with multiple versions, multiple languages, and a need for interactive React components inside Markdown.

Bad at: being simple. It’s a React app pretending to be a docs tool. Heavy dependency tree, slower builds than Hugo or Zola, and MDX errors can be painful to debug for writers who don’t know React.

Trajectory: still widely used for open-source project docs, but losing ground to Astro’s Starlight, which covers most of the same use cases with less weight.

Use if: you need versioned multilingual docs with React islands, and your team is already React-shaped. Otherwise consider Starlight first.

MkDocs (with Material)

MkDocs is a Python Markdown docs generator. The Material for MkDocs theme, maintained by Squidfunk, is what most people actually mean when they say “MkDocs” - tabs, admonitions, instant search, mobile-first design, dark mode.

Built in: Python.

License: MkDocs is BSD-2-Clause. Material for MkDocs is MIT, with a paid “Insiders” tier that ships new features to sponsors first before they land in the free version (usually after several months to a year). The base theme is fully functional without Insiders. If a tutorial references a Material feature you can’t find, check whether it’s been released to the public tier yet.

Good for: project documentation. Very good at it. The Material theme is best-in-class for mobile reading experience and search UX. The plugin ecosystem is mature.

Bad at: cross-referencing at scale. No :ref: equivalent, no semantic links, no AST-level extensibility. If you rename a page, you fix links by hand. For docs under ~1000 pages this is fine; beyond that, Sphinx starts winning on maintenance.

Trajectory: stable and healthy. Squidfunk makes a living from the Insiders sponsorship model, which makes Material one of the more sustainably-funded open source projects in the SSG space.

Use if: you want pleasant-to-read project docs without ceremony, and your docs fit in one logical set with straightforward navigation.

Sphinx (with MyST)

The 18-year-old Python documentation workhorse. MyST Markdown is a modern CommonMark-compatible dialect that gives you Sphinx’s power (directives, roles, cross-references) without writing reStructuredText.

Built in: Python.

License: BSD-2-Clause.

Good for: large, structured, long-lived technical documentation. Cross-references that survive file moves. Glossaries, indexes, LaTeX output, ePub output, multi-language builds via gettext. Python-ecosystem projects.

Bad at: fast iteration and writer onboarding. Even with MyST, the mental model is heavier than MkDocs. Theme customization is less pleasant than Material. Builds are slower.

Trajectory: stable, foundational, not going anywhere. Jupyter Book and myst-parser keep the ecosystem evolving around it. Sphinx’s own documentation at sphinx-doc.org is built with Sphinx, as are the CPython language docs, NumPy, SciPy, pandas, scikit-learn, Django, and most of the scientific Python ecosystem - the clearest concrete evidence for the institutional-backing claim above.

Use if: your docs are big, structured, reference-heavy, or you need multi-format output for academic or compliance reasons. Also: if you’re documenting a Python project with lots of autodoc.

mdBook

The Rust project’s own book tool, maintained in the rust-lang GitHub organization alongside the Rust compiler itself. Used to render “The Rust Programming Language” and a lot of other programming books. Institutional backing is as strong as any SSG on this list - not a hobby project that can be abandoned by a single maintainer.

Built in: Rust. Single binary.

License: MPL-2.0.

Good for: programming books and linear-reading technical manuals intended for the web. Fast, simple, clean default theme.

Bad at: anything that isn’t a linear book. Flat SUMMARY.md navigation doesn’t scale past a few hundred pages. No semantic cross-references, no content reuse primitives beyond community plugins, no native PDF/ePub output (the plugins that exist are brittle), no i18n story.

Trajectory: stable, narrow-purpose, well-maintained.

Use if: you’re writing a programming book or manual that lives on the web, under ~200 pages, and you want it to look like the Rust Book. Outside that niche, pick Quarto or Sphinx.

Quarto

A scientific and technical publishing system from Posit (formerly RStudio). Built on Pandoc. Renders one Markdown source (.qmd) to HTML, PDF, Word, ePub, presentations, websites, books.

Built in: TypeScript, wrapping Pandoc and Knitr/Jupyter.

License: MIT. Maintained by Posit (formerly RStudio); the company has commercial products but Quarto itself is free software.

Good for: books, papers, articles, and any writing that mixes prose with executable code in R, Python, Julia, or Observable. Kieran Healy used Quarto to produce his Data Visualization book end-to-end, with figures generated by live R code in the manuscript and both PDF and web output from a single source.

Bad at: arbitrary web design. You get Quarto’s layout conventions; fighting them for custom layouts is possible but unpleasant. Not a replacement for Sphinx when you need deep cross-referencing semantics.

Trajectory: still smaller than Sphinx in deployed-site count, but growing fast. Clearly the right tool for scientific and technical book authors in 2026.

Use if: you’re writing a book or long-form technical piece and you want HTML, PDF, and ePub from one source without a LaTeX PhD.

Publish a book or knowledge base: Mdbook guide

A minimal mdBook on GitHub Pages like this one

The bootstrap-mdbook script scaffolds an mdBook book, installs the newest mdBook release that has been out a week and pins it, writes a first-party GitHub Pages deploy workflow, and opens a live preview. The published site has the usual mdBook furniture — left-sidebar table of contents, built-in search, print/PDF view — with no analytics, no tracking, and no request from its pages to any other site (mdBook’s Rust playground, which would make one, is off; see “Code line numbers”), and a reading theme: self-hosted Charter, a ~700px measure, a warm dark palette, one hairline closing each chapter’s title, inked-and-underlined links, and a sidebar masthead that behaves like the menu bar. Out of the box, a button in the menu bar lets each reader switch between it and its light counterpart, starting on whichever their system asks for; THEME can instead follow the system’s setting with no button, or give every reader the dark palette. A button beside print lets readers keep the book on their own devices: saved in the browser, or downloaded as a copy for any computer, online or air-gapped (OFFLINE).

It is a one-shot bootstrapper. It seeds the project once; after that you own the files and edit src/ directly. Re-running is the update path (new releases, changed toggles), and it regenerates the script-owned files — see “Re-running vs. hand edits.”

Linux x86_64 only, with a glibc new enough for mdBook’s and lychee’s Linux builds. For macOS or ARM, see “Other platforms” at the end.

Two ways to set this up

There are two ways to build a site like this one. They differ only in how the files arrive; the result — the theme, the deploy workflow, the furniture — is identical.

  1. Clone the reference repo. The fastest start: you get a working tree, and change the title, author, and repo rather than writing scaffolding.

    git clone REPLACE_WITH_REPO_URL mybook
    cd mybook
    rm -rf .git
    

    Replace REPLACE_WITH_REPO_URL with the repository URL. Don’t skip rm -rf .git, which prints nothing: it deletes the clone’s history. Kept, the reference repo’s commits would go into your repo with your first push, each with its author’s name, email and time zone, and with OFFLINE on (the default) the book’s versions would count them. With the history gone, the script makes a fresh repository and a first commit of your own.

    A clone also carries that repo’s URL baked into book.toml (site-url, edit-url-template), README.md, custom.js (MDB_REPO) and offline.py (HOME). Point them at your own empty repo: delete the clone’s README.md and run the script with your GIT_REPO_URL set. It adds origin there, regenerates book.toml, custom.js and offline.py, and writes a new README.md because there is none. It does that in PROJECT_DIR/BOOK_NAME, so that path must be the clone; run the clone command above in ~/Desktop and the defaults already match. (Or add origin yourself, with git init -b main && git remote add origin git@github.com:USER/REPO.git, and run the script with GIT_REPO_URL empty: it reads the repo from origin.) Fonts, tagging, and Pages are then the same as the path below.

  2. Run the script. The rest of this guide, from “Run it” on. Edit the toggles, run it once with no arguments, and it installs mdBook, writes every file, and opens a preview. Re-running is the update path.

Run it

  1. Open bootstrap-mdbook.sh and edit the toggles at the top (below).

  2. Run it with no arguments:

    bash bootstrap-mdbook.sh
    

Before the preview opens, the script runs ./build, which builds the book into book/ and checks every link and anchor in it. If any is broken, the run stops there and lists them, with no preview (see “The link check”); fix them and run the script again. Otherwise the preview opens on a free local port the script picks and prints (http://127.0.0.1:<port>). It builds into .preview/, rebuilding as you edit, so book/ keeps what ./build made; it checks no links, and has no offline button, which needs what ./build adds. With OFFLINE on, the run serves book/ too, button and all, on the first free port from 8000, and prints its address: that page is the book as ./build left it, and doesn’t follow your edits (see “Read offline”). Ctrl-C stops both. Re-launch the preview alone later with:

cd <project> && ./bin/mdbook serve --open -n 127.0.0.1 -p <port> -d .preview

A second book alongside the first just needs a different -p port.

Before each push, run ./build in the book’s folder: the deploy runs the same check and stops on the same breaks.

Audit it first. Network-wise it asks api.github.com for the releases of mdBook, lychee and the workflow’s three actions, asks github.com which commit each action’s release names (git ls-remote), and downloads the mdBook and lychee release tarballs from github.com, which redirects the download to its file host, release-assets.githubusercontent.com (see “Releases and updates”). It installs nothing with apt or sudo: mdBook and lychee go into the book’s bin/. It needs curl, python3 and git; if one is missing it stops before it writes anything and prints the line that installs it (sudo apt-get install … on Debian or Ubuntu). python3 reads GitHub’s answers, runs offline.py, and, with OFFLINE on, serves book/ on 127.0.0.1 while the preview runs. Only when HEADING_NUMBERS=true does it also ask crates.io for mdbook-numbering’s versions, need a C compiler (build-essential on Debian or Ubuntu; it stops the same way without one), and install the Rust toolchain into ~/.cargo and ~/.rustup (via rustup, from sh.rustup.rs and static.rust-lang.org) to compile that preprocessor, whose sources cargo fetches from crates.io (index.crates.io, static.crates.io); rustup also adds a line to your shell’s startup files, such as ~/.profile, that puts ~/.cargo/bin on your PATH, where ./build finds mdbook-numbering, in terminals opened after that: in the one the run was in, run . "$HOME/.cargo/env" first, or ./build stops with The command `mdbook-numbering` wasn't found. The run says so at its end, or under ./build’s report when that stops it. A cargo already on your PATH is used instead of rustup’s: one from your distribution is often older than mdbook-numbering needs, and the run then stops with cargo’s reason and a pointer to rustup; one that does build it puts it in ~/.cargo/bin, which a distribution’s PATH often lacks, and the run says that the same way. That path adds a few minutes to the first run.

The settings at the top must be this version’s: a block pasted from an older version stops the run before it reads or writes anything, with each setting to add, its lines printed for you to paste, and each to delete (see “Upgrading from v55 or earlier”, and for THEME, “Upgrading from v53 or earlier”).

What it writes

Into the project folder (PROJECT_DIR/BOOK_NAME):

  • book.toml — mdBook config, seeded from the toggles. Script-owned.
  • src/SUMMARY.md, src/about.md, src/chapter_1.md, src/demo.svg — starter content (the SVG is the demo chapter’s picture), written only if src/SUMMARY.md doesn’t already exist. Yours thereafter.
  • custom.css, custom.js — the theme and behaviours. Script-owned.
  • version.js — the deploy stamp the sidebar footer and the Last updated lines read: four placeholders the deploy workflow fills in. Script-owned.
  • theme/head.hbs — clears a theme saved in the reader’s browser, or with THEME="picker" one the button doesn’t offer (see “Light and dark”), and keeps night modes built on Dark Reader off the book’s palettes (see “Why the dark palette comes first”); with OFFLINE="full" it also links the web app manifest (see “Read offline”). Script-owned.
  • theme/fonts/fonts.css — keeps mdBook’s own fonts out (see “The reading theme”). Script-owned.
  • theme/favicon.svg — written from src/logo.svg if that exists, otherwise drawn from FAVICON_TEXT. Script-owned while either source is set; it is never deleted, only overwritten. See “The favicon.”
  • README.md — title, plus a live-site link if the book knows its repo (GIT_REPO_URL, or origin), written only if there’s no README.md yet. Yours thereafter.
  • build — the build and link check: ./build builds the book and stops on any broken link or anchor, listing each by the file and line it’s written on. The script runs it before the preview, and the deploy workflow runs the same file. Script-owned. If the book already has a build the script didn’t write, the run stops before changing anything, so yours is never overwritten: move it aside (mv build build.own) and run again.
  • offline.py — OFFLINE’s build step (see “Read offline”). ./build runs it after mdBook, whatever the setting: it always writes the site’s sw.js, and unless OFFLINE is "none" it makes the download. Python 3.7 or later, standard library only. Script-owned.
  • .github/workflows/deploy.yml — the Pages deploy workflow; it pins mdBook, lychee and its three actions, stamps the version, the commit’s time and the per-page dates into version.js, and runs ./build before deploying. Script-owned.
  • bin/mdbook + bin/.mdbook.sha256 + bin/.mdbook.version, bin/lychee + bin/.lychee.sha256 + bin/.lychee.version — the pinned binaries, each with its tarball’s digest and the version it’s for. bin/ is gitignored.
  • .gitignore — ignores bin/, book/ and .preview/ (the preview’s build).

“Script-owned” means a re-run regenerates it and discards hand edits.

One path under src/ is reserved rather than written: chapters you put in src/unlisted/ and list in SUMMARY.md are published but kept off the sidebar, the arrow chain and the search index. See “Unlisted chapters.”

The toggles

Edit these at the top of the script.

  • PROJECT_DIR — parent folder the book is created in (e.g. $HOME/Desktop). A leading ~ and relative paths are resolved for you.
  • BOOK_NAME — the project folder’s name, under PROJECT_DIR (default mybook). Readers see it only in the offline download’s name (see “Read offline”). ASCII letters, digits, ., _ and - only, not starting with . or -: the characters GitHub allows in a repository name, so the folder and the repo can share a name, and nothing in it expands when you paste the cd line the script prints at the end. The repo’s own name and the Pages URL still come from GIT_REPO_URL.
  • BOOK_TITLE — the title readers see, written to book.toml and used for the masthead, the drawn favicon, README.md and the starter About page. Apostrophes, quotes and / are safe; it must not contain a newline. Change it and re-run, and the same book is retitled in place; src/about.md and README.md, being yours, keep the title they were written with.
  • BOOK_AUTHOR — author, written to book.toml. No page shows it (see “What a push publishes”).
  • BOOK_DESCRIPTION — one line about the book; "" (default) for none. Written to book.toml as mdBook’s description, which every page carries in its <meta name="description"> for search engines, and with OFFLINE="full" the web app’s description (see “Read offline”). It must not contain a newline.
  • DEPLOY_BRANCH — git default branch, and the branch whose pushes trigger CI (default main).
  • HEADING_NUMBERS — true adds automatic in-page heading numbers (H2–H6) via the mdbook-numbering preprocessor (its newest version that has been out a week, pinned in the workflow; see “Releases and updates”). This path also installs Rust and compiles the preprocessor. Default false.
  • SIDEBAR_NUMBERS — true (default) shows mdBook’s built-in sidebar chapter numbers (1., 1.1.); false hides them (no-section-label). Independent of HEADING_NUMBERS: that numbers headings in the page, this numbers chapters in the sidebar.
  • THEME — which of the theme’s two palettes readers get. "picker" (default): a button in the menu bar with Auto (the reader’s browser or OS setting), Light and Dark, starting on Auto, the choice kept in the reader’s browser. "auto": light or dark as the reader’s browser or OS is set, with no control in the page. "dark": the dark one, for every reader, whatever their browser or OS is set to. Charter and the rest of the theme in all three. See “Light and dark.”
  • GIT_REPO_URL — your repo URL, e.g. https://github.com/user/repo (https github.com only, and nothing after the repo; a trailing / or .git, as GitHub’s Clone button gives, is dropped). Wires the edit (pencil) icon in the top bar, links the sidebar footer’s commit hash, sets site-url (so the 404 page resolves its assets at any depth), fills the README’s live-site link when the README is first written, and sets the git origin remote (converted to SSH) so your first push needs no manual git remote add: added or changed, and said, only when it isn’t that address already. Left empty, the script reads it from the book’s origin remote when that is on github.com, and says so (GIT_REPO_URL is empty; using origin: …), so a fresh copy of the script run on an existing book keeps those links; origin itself is then left as it is. With no such remote the pencil and site-url are omitted, the hash is plain text, and the README is just the title; set it later and the README stays as it is, so add the link yourself, or delete README.md and re-run. No repo icon is written — see “The top-bar icons.”
  • CODE_LINE_NUMBERS — true (default) numbers language-fenced code blocks of ten lines or more. See “Code line numbers.”
  • SIDEBAR_MASTHEAD — text (default), none, or image. What fills the sidebar band opposite the menu bar. See “The sidebar masthead.”
  • FAVICON_TEXT — what theme/favicon.svg draws when there is no src/logo.svg to copy. "auto" (default) takes the first alphanumeric character of BOOK_TITLE, a letter as a capital (M for mybook); any other string is drawn as typed, first three characters, case preserved; "" draws nothing and leaves mdBook’s own bundled icon alone. src/logo.svg always wins. See “The favicon.”
  • LAYOUT — "scroll" (default) shows each chapter on one page. "h2" shows it one section at a time, each H2 starting one, its H3–H6 in it; "nested", one section at a time, every H2–H6 starting one. "picker" adds a button with which each reader chooses Scroll or Pages (as "nested"), starting on Scroll. It’s done in the reader’s browser: the book is built the same in every layout. See “Layout: a section at a time.”
  • OFFLINE — "full" (default) adds a button beside print offering both: saving the book in the reader’s browser, updated when they ask, and a copy to download, to keep on any computer, online or air-gapped. "download" offers the download only; "none" adds nothing a reader sees. See “Read offline.”

Upgrading from v62 or earlier: re-run, then commit what it changed: theme/head.hbs. It now carries <meta name="darkreader-lock">, so night modes built on Dark Reader, Brave’s and Firefox’s on iPhone and iPad among them, leave the book’s palettes alone: Light shows light and Dark the book’s own dark, where before they repainted both, Light in dark olive (see “Why the dark palette comes first”). Readers who saved the book are offered the update.

Upgrading from v61 or earlier: THEME now defaults to "picker", which a new book gets; the settings block you carry over keeps its THEME. With "auto" or "picker", re-run, then commit what it changed: custom.css. The light palette now declares color-scheme: only light, so a reader’s Light stays light in a browser that darkens sites for a reader in dark mode, as Chrome’s dark theme for sites does; before, that browser turned it dark grey (see “Why the dark palette comes first”). Readers who saved the book are offered the update. With "dark", nothing the script writes has changed. If you pasted “A page for readers” into src/ and your book has the button, take its Paintbrush row out of the comment at the end of the icon table, to sit second, and delete the comment.

Upgrading from v60 or earlier: re-run; there’s nothing to commit, since no file the script writes has changed. With OFFLINE on, the run now serves book/ beside the preview, on the first free port from 8000, and prints its address in place of a command to run in another terminal: the offline button can be checked there, and Ctrl-C stops both (see “Read offline”). The command for serving book/ by hand gains -I.

Upgrading from v59 or earlier: re-run, then commit what it changed: custom.css and custom.js. Charter’s files give every system but Windows a descent with the wrong sign, which set each line of Charter 0.24em low in its box: a button’s label below its middle, the book’s title and the icons in the top bar low, and a line with inline code taller than the rest. Each @font-face now sets the files’ Windows metrics, which Chromium-based browsers and Firefox take on every system (see “Charter fonts”); in those, off Windows, Charter text moves up in its line (a chapter’s title sits further above its rule), and a page with inline code gets shorter. On the right of the top bar, the pencil now comes before print, and the offline button last, spaced as they are: print and the download sit together, and the panel opens under its button. Under Download a copy, the panel says again to unpack it and open index.html in the folder it makes, which v58 and v59 left out. If you pasted “A page for readers” into src/, move its Pencil row above the Printer row.

Upgrading from v58 or earlier: re-run, then commit what it changed: custom.css, custom.js, version.js, build, offline.py, theme/head.hbs, theme/fonts/fonts.css and the workflow. Their comments, and the script’s, are cut to a line where the code needs one, and the script’s longer messages to what’s wrong and what to do, with the rest in this guide. Nothing works differently, and the pages are the same; a copy a reader saved is offered an update all the same, since the scripts it holds changed.

Upgrading from v57 or earlier: re-run, then commit what it changed: custom.css and custom.js. In the offline panel, Save for offline, Update to v… and Download a copy are big buttons, each with a line under it saying what it’s for, and the section headings are gone; the progress bar takes the theme’s accent. The download is offered as a copy to keep on any computer, online or air-gapped. On an iPhone or iPad, the panel tells readers to add the book to the Home Screen first. While a saved copy is behind the site, each page it serves says so above its text (see “Read offline”). The run’s last lines say to start the local server in another terminal. If you pasted “A page for readers” into src/, take its Cloud row, and the Read offline row in its table for phones, from the page as printed now. This guide quotes two of mdBook’s directives, which v33 left live: a book that publishes the guide stopped at ./build, with stopped: mdBook reported an error, above.; v34 escapes them (see “The link check”).

Upgrading from v56 or earlier: re-run, then commit what it changed: custom.js and build. In WebKit, the engine of Safari and GNOME Web (tried in GNOME Web’s), a link to a passage in a section (#beta:~:text=…) now opens at the passage; v56 put the section’s top back once the page had loaded. A run now stops before it writes anything when there’s a file or folder it would write and can’t, and a run as root on a book that isn’t root’s stops (see “Re-running vs. hand edits”). A .gitignore whose last line had no newline got bin/ on the end of that line, so a first commit took in bin/’s binaries and downloads; a run now puts bin/ on a line of its own, and git rm -r --cached bin takes them out of the next commit, leaving the files where they are.

Upgrading from v55 or earlier: the settings at the top changed: LAYOUT takes the place of SPLIT_HEADINGS, so put your values into this version’s block. An old block stops the run before it reads or writes anything, with the LAYOUT lines to add, their value the one that gives what yours asked for ("h2" for "h2", "nested" for "nested", "scroll" for "none"), and SPLIT_HEADINGS to delete. Then re-run, and commit what it changed: book.toml, build, custom.js, offline.py, with "h2" or "nested" custom.css too, and the deletion of split-headings.py, which the run removes, and says so; git add -A takes the deletion in. A split-headings.py of your own, without the script’s Script-owned line, is left alone. A split book now shows its sections one at a time on the chapter’s own page, not on pages of their own (see “Layout: a section at a time”): the split-off pages’ addresses (guide/configure.html) and the redirect pages ./build wrote for them are gone, so a bookmark or a link from another site to one gets the 404 page, as the stop says; a link to a heading’s anchor (guide.html#configure) works as before. A "none" book without HEADING_NUMBERS=true loses the redirect pages ./build wrote at those addresses too (guide/configure.html to guide.html#configure): if it was ever split, they now get the 404 page; with it, v55 wrote none. A link in the book written straight to a split-off page (guide/configure.md), which v55 pointed at its heading in every mode but with HEADING_NUMBERS=true, now stops ./build (there's no src/guide/configure.md): write it guide.md#configure. HEADING_NUMBERS=true now goes with any layout.

Upgrading from v54 or earlier: re-run, then commit what it changed: custom.css and build, and theme/favicon.svg when FAVICON_TEXT is "auto" and the title’s first letter or digit is a lower-case letter, now drawn as a capital. The light palette’s accent, its strings and its diff additions are a shade darker (#9d4909, #007f00, #207d36), and the theme menu, the offline panel and the copy buttons take the palette’s colours. Code prints in ink. On a page with an .mdb-bleed block, everything but that block now sits as it does on a page without one; before, that page’s column was 1000px wide with each other block held to the measure in its middle, so a raw <img> or other inline HTML, or a float, sat at the wide column’s edge, and a block with side margins of its own, such as a <figure>, lost them. A page carrying both markers now takes mdb-wide’s 1000px; before, mdb-bleed held its prose to the measure. A re-run with GIT_REPO_URL set leaves origin alone, and says nothing, when it is already that repo’s SSH address (git@github.com:user/repo.git); another spelling of it is rewritten to that, and said.

Upgrading from v53 or earlier: the settings at the top changed, so put your values into this version’s block rather than your old block into this version: an old block stops the run before it reads or writes anything, and lists each setting to add, its lines printed for you to paste, and each to delete. THEME takes the place of THEME_MODE, LIGHT_THEME, PREFERRED_LIGHT and PREFERRED_DARK, and the line the stop prints has the value that gives what yours asked for: "dark" for LIGHT_THEME=false, "auto" for "fixed" with LIGHT_THEME=true, "picker" for "default" with LIGHT_THEME=true. mdBook’s own themes are gone: a "default"-mode book now gets this theme, Charter included, and with "picker" its button offers Auto, Light and Dark, in this theme’s palettes. A PREFERRED_DARK of coal or navy becomes ayu, whose code colours differ. A reader’s saved Coal, Navy or Rust is cleared.

The phone copy (.zim, read in Kiwix) is gone, and with it zim-tools and librsvg2-bin: the download is the laptop’s alone, and on a phone readers save the book in the browser (see “Read offline”). The web app’s icon is now the favicon itself. The script installs nothing with apt or sudo: if curl, python3 or git is missing (or, with HEADING_NUMBERS, a C compiler), it stops and prints the line that installs it. zim-tools and librsvg2-bin can go if nothing else of yours uses them.

The script no longer names a release of anything. Each run takes the newest that has been out a week, checked against GitHub’s digest (see “Releases and updates”), so this re-run may move mdBook, lychee, the workflow’s actions and, with HEADING_NUMBERS, mdbook-numbering to other releases: usually newer ones, but an older one where the release you have came out less than a week ago. Every book stays on mdBook’s 0.5 series. The run needs api.github.com (with HEADING_NUMBERS, crates.io too) for what the workflow doesn’t pin yet; after that, a run that can’t ask keeps what the workflow pins and says so.

Then git add -A, and commit what the run changed: the workflow (new pins, each action’s release named after its commit, both jobs on ubuntu-latest, the two publishing permissions on the deploy job alone, checkout keeping no token, no actions/configure-pages or apt step, and the pages’ dates in UTC), .gitignore (.preview/), build (it now stops when mdBook reports an error: see “The link check”), book.toml, custom.css, custom.js, offline.py and theme/head.hbs. A "default"-mode book also gains theme/fonts/fonts.css and, if it had LIGHT_THEME=true and OFFLINE wasn’t "full", theme/head.hbs: new files, which git commit -a would leave out. Deployed without head.hbs, a "picker" book keeps a reader’s saved Coal, Navy or Rust, and shows mdBook’s own theme. The first commit, in a new book, is now made only with a git identity you’ve set, and in UTC (see “What a push publishes”). A ```rust block is no longer one of mdBook’s playgrounds (see “Code line numbers”): it loses its Run button, its page stops asking play.rust-lang.org for anything, and one of ten lines or more gets line numbers unless it hides lines (# ); ```rust,mdbook-runnable keeps a block runnable. If you pasted “A page for readers” into src/, take its Cloud row, and with the theme button its Paintbrush row, from the page as printed now.

Upgrading from v52 or earlier: mdb-guide v31, under “The toggles”, has the note for v52 and, after it, where the older ones are. Follow those that apply, oldest first, up to v52’s, then the eight above.

Re-running vs. hand edits

A re-run looks the releases up again and takes a newer one once it has been out a week (see “Releases and updates”), refreshes bin/mdbook and bin/lychee when their release changed, re-pins the workflow, and regenerates every script-owned file above from the toggles, then runs ./build before the preview and stops there on a broken link. Your src/ is preserved (the starter content is only written when src/SUMMARY.md is absent), and so is README.md (written only when there’s none). A re-run also clears theme/head.hbs and theme/fonts/fonts.css and writes back what the current settings call for, so a change of THEME or OFFLINE never leaves a stale one behind: a head.hbs clearing every saved theme in a "picker" book would stop the button remembering any choice. A .git git won’t work in stops a run before it writes anything: another user’s repository (git calls it dubious ownership; a run with sudo leaves the book root’s), for which the message gives the chown that makes it yours again, naming a worktree’s or a submodule’s repository too when that’s outside the book; a .git with something in it you can’t read (a git command run with sudo can leave files of root’s there), for which it gives the chown and chmod that make it yours and readable; or a .git that isn’t a repository (one emptied by hand), which the message says to move aside. A file or folder the run writes that it can’t (a run with sudo leaves them root’s, and a copy can arrive read-only) stops it the same way, before it writes anything, listing each (ERROR: this run writes these in …, and can't; nothing has been changed:) with the chown and chmod that make them yours, or, for one on a filesystem mounted read-only, or marked immutable or append-only, saying so (and for the mark, the chattr that takes it off); so does a first run whose book folder can’t be made where PROJECT_DIR says. bin/ is checked only when the run installs a release there or HEADING_NUMBERS is on (rustup and cargo build there), so a bin/ that has this run’s releases already (a symlink to a shared folder of them, say) can be one you can’t write; and a .git you can’t write stops a run only when it would set origin there, which it doesn’t when origin is that address already, or make the first commit. A run as root (with sudo, su, doas or pkexec, a root login, or a container’s root) on a book that isn’t root’s stops before any of that, since nothing here needs root: run it as the book’s owner (in a container, docker run --user with the owner’s uid and gid does that). With HEADING_NUMBERS, a run as root stops too when HOME, or the folder cargo or rustup uses (CARGO_HOME and RUSTUP_HOME, or ~/.cargo and ~/.rustup), isn’t root’s, as rustup and cargo can write there.

So: edit src/ and README.md freely and re-run whenever you like. But hand edits to book.toml, custom.css, custom.js, version.js, build, offline.py or the workflow do not survive a re-run. Inside theme/ the script touches exactly three files — theme/head.hbs, theme/fonts/fonts.css and theme/favicon.svg — and hand edits to those three go the same way; anything else you put in theme/ (a favicon.png, an index.hbs, a css/chrome.css override) is left alone, run after run. To change a script-owned file, either change the toggle that drives it and re-run, or edit it and then don’t re-run. To take a newer release while keeping such hand edits, see “Releases and updates” (the manual path).

The reading theme

custom.css carries the whole reading theme, with theme/head.hbs and theme/fonts/fonts.css, whatever THEME is. What you get:

  • Charter, self-hosted, if you supply the fonts (next section). Otherwise a Palatino/Noto/Liberation/Georgia serif fallback renders and no @font-face is emitted (so nothing 404s).
  • A ~700px measure (--content-max-width: 700px) for a comfortable line length. Widen one page with .mdb-wide, or one block while the prose keeps its measure with .mdb-bleed (see “Wide pages and wide blocks”).
  • A 60px menu bar (mdBook ships 50px), giving a 24px serif title room to breathe above the body text that scrolls under it.
  • A full palette. mdBook 0.5.4 defines 40 variables per theme, and the script overrides every one that paints — not just the page and sidebar, but blockquotes, tables, the search UI, icons, the separator, the code-block ground, the theme menu, the offline panel and the copy buttons (--theme-popup-bg, --theme-popup-border, --theme-hover), and the light palette’s scrollbar (--scrollbar), which follows --sidebar-fg as mdBook’s dark one does — bar the five admonition accents on screen, which keep mdBook’s ayu and light values. Without this, the first table or blockquote on a page shows the stock theme’s blues. With THEME at "auto" or "picker" the dark palette is the base and the light one is gated behind prefers-color-scheme: light — see “Why the dark palette comes first” below.
  • A sidebar that sits close to the page. --sidebar-bg is about two points of CIELAB lightness off --bg in both palettes: enough for the column to read as its own surface, little enough that the page reads as one tone rather than two panels. It does not match the blockquote/code ground, which is a heavier tint doing a different job. It cannot go to zero either — mdBook draws no border on .sidebar, so this tint is the only boundary the column has.
  • One hairline closing each chapter’s title block — the H1 and its subtitle, when it has one — with the Last updated line the deployed site adds hanging under the rule. Headings below the title draw none unless you write --- straight after one. A chapter’s --- and a SUMMARY.md --- separator take the same weight and colour (mdBook draws those two differently by default). See “Headings and rules.”
  • Headings that sit close to their own text. Each heading’s margins scale with its own size, and whatever follows a heading starts right under it. H3 is a step above the text; H4 to H6 are text size, told apart by style: bold italic, italic, muted italic.
  • Inked, underlined links (mdBook’s default is undecorated), including search results; an <a> with no address, such as <a id="here">, isn’t a link: it takes the text’s colour and no underline, on hover and in print too. A link in the text turns blue once followed; footnote numbers, the links in notes and heading anchors keep their colour, and in print every link is ink.
  • Nested numbered lists count 1.1, 1.2. Number each level the ordinary way (1., 2., 3.), indented under the item above, and the stylesheet draws 2.1, 2.2. A list that starts at another number keeps the browser’s own numbers, because a stylesheet counter can’t read where a list starts.
  • A repainted code-block ground. mdBook paints code backgrounds from the highlight.js stylesheet, not a theme variable, so the palette can’t reach it; the script repaints it (--code-bg) onto the same raised tint blockquotes use, and leaves the syntax token colours as the highlight sheet has them, bar two too faint on that ground: comments take --code-comment (ayu’s grey is about 2.7:1 on it), and, in the dark palette, so does the punctuation of HTML, XML and JSX tags, <, =, > (ayu’s #00568d, about 2:1); a tag’s name and attributes keep their colours.
  • Inline code with a ground of its own. mdBook paints none — it gives inline <code> padding and a radius and leaves the fill to whichever highlight sheet is live, which is off this palette either way. The script gives it a warm fill (--inline-bg), the theme’s accent for the text, and a hairline drawn as an inset shadow so the pill’s metrics don’t shift.

theme/head.hbs clears any theme saved in localStorage before mdBook’s theme script runs, or with THEME="picker" any the button doesn’t offer — necessary because every GitHub project site under one user.github.io shares one localStorage origin, so a theme chosen in some other book would otherwise pin this one. It also carries <meta name="darkreader-lock">, for browser night modes (see “Why the dark palette comes first”).

theme/fonts/fonts.css deliberately declares no faces. Its mere presence makes mdBook emit only the fonts in theme/fonts/ (none) instead of its built-in ten Open Sans and one Source Code Pro — which this theme doesn’t use. Measured against 0.5.4 that drops ~493KB, about half of a default book.

Light and dark

THEME decides how the two palettes reach readers:

  • "dark": the dark palette, for every reader, whatever their browser or OS is set to. Only the dark palette is written.
  • "auto": both, and the reader’s own light/dark setting — the browser’s, or the OS preference the browser reports — decides which one they see. An open page follows a change of it. There’s no control in the page.
  • "picker" (default): as "auto", plus mdBook’s theme button in the menu bar, between the sidebar and search buttons. Its menu has Auto (the reader’s setting, as with "auto"), Light and Dark. It starts on Auto, and a choice is kept in the reader’s browser for their next visit.

The two palettes are painted over two of mdBook’s themes, light and ayu. mdBook picks the code’s highlight.js stylesheet in JavaScript, from the theme name — book.js maps ayu to the ayu sheet, coal/navy to Tomorrow Night, and anything else to the light sheet — so the palette and the syntax colours are selected by two different mechanisms, and only the theme name keeps them in step: the dark palette is drawn for the ayu sheet. With "dark", book.toml sets both default-theme and preferred-dark-theme to ayu. That’s easy to read as tidying-up and isn’t: leave default-theme = "light" while writing a dark-only palette and every reader whose OS is set to light gets light syntax tokens on a near-black code block. With "auto" and "picker", default-theme is light and preferred-dark-theme is ayu, and mdBook’s script takes the reader’s setting.

The button ("picker") is mdBook’s own, trimmed: custom.js removes the Rust, Coal and Navy rows and names Ayu’s row Dark. Removes, not hides. mdBook moves keyboard focus through the list by sibling — li.nextElementSibling.querySelector('button').focus() — and focus() on a display:none button silently does nothing, so a hidden row would stop the arrow keys there. It is the same reason custom.js removes, rather than hides, the prev/next links into src/unlisted/. theme/head.hbs clears a saved theme other than light or ayu (Auto saves none), such as one another book on the same USER.github.io address saved, before mdBook’s script reads it. Books on one address share the reader’s choice: Light chosen in one book with the button is Light in another, and a book without the button clears it, since there a saved theme would pin the book to it.

Testing it: use your browser’s DevTools colour-scheme emulation rather than the OS toggle. Some Linux desktops don’t report the preference to the browser at all, so the OS switch can look like it does nothing when the book is fine. On a phone, a Light that still shows dark means the browser is forced to darken every page with Chromium’s Auto Dark Mode flag, which no page can opt out of (see “Why the dark palette comes first”).

The top-bar icons

On the left the top bar carries the sidebar’s and search’s buttons, and with THEME="picker" the theme button between them. With a repo known to the book (GIT_REPO_URL, or an origin on github.com), it carries a print icon and a pencil on the right that opens the current page in GitHub’s web editor (it auto-forks for readers without write access). The pencil exists only when book.toml carries edit-url-template, which the script writes from that URL, so a book built without one has no pencil, on any page; print.html and the 404 page never have one, since neither comes from a single source file. On the right, custom.js puts the pencil in front of print (mdBook puts it after), and with OFFLINE on, as it is by default, the offline button comes last: print and the download sit together, and the panel opens under its button (see “Read offline”). It does not carry a repository icon, though mdBook offers one: the icon would sit beside the pencil and lead to the same repository the sidebar footer’s commit link already reaches, and two icons to one destination is one too many.

The script leaves git-repository-url out of book.toml to drop it. mdBook gates the repo icon and the pencil on independent conditions in its template, so omitting that one key removes the repo icon and leaves the pencil untouched — no CSS hiding, nothing overridden. Add the key back by hand if you want the icon, but remember book.toml is script-owned: the next re-run regenerates it without.

Why the dark palette comes first

This applies when THEME is "auto" or "picker". With "dark" there is no swap at all — both book.toml keys name the dark theme, so the served class is already the right one — and the palette below collapses to a single class with no media query under it.

mdBook does not resolve the theme in CSS. It bakes default-theme into the served markup — with both palettes live, every page ships as <html class="light"> — and an inline script at the top of <body> swaps that class after the stylesheets have already resolved. On a dark-mode reader’s machine that means every navigation paints a complete light frame before the swap lands.

Usually you never see it. A force-dark browser extension does. It reads the served frame, computes its inversion from the light palette, then holds that inversion over the dark palette the swap installs — so the page ends up part inverted-cream, part extension grey, in three or four colours at once. It looks like the theme is broken. The theme is fine; mdBook handed the extension the wrong frame to read.

So custom.css puts the dark palette on both class names and gates the light palette behind a media query:

html.light,html.ayu{ …dark… }
@media (prefers-color-scheme: light){
html.light{ …light… }
}

Both selectors are (0,1,1), which is what beats mdBook’s own .light/.ayu at (0,1,0); a bare html{…} would be (0,0,1) and lose to it, so the class has to stay in the selector even though the media query alone reads as sufficient. The light block comes second, so at equal specificity it wins wherever its query matches. Four states, all measured in Chromium against 0.5.4:

reader’s OSbefore the swapafter the swap
darkdarkdark
lightlightlight

No light frame ever paints under a dark OS, and each palette is still written exactly once.

With "auto", the light palette applies only when the OS asks for light. With "picker" a reader can choose Light under a dark OS, so the light palette is written a second time, for html.js.light: mdBook’s script adds the js class as it sets the theme, so that selector holds once the reader’s choice is in, whatever the OS asks, and the served frame before it stays dark.

A browser that darkens sites for a reader in dark mode, as Chrome’s dark theme for sites does, would darken that choice too: measured in Chromium at a phone’s size, a picked Light painted #1e1e1a, not the palette’s #fdfcf7. So the light palette declares color-scheme: only light rather than light, the opt-out Chrome documents for that darkening, and Light stays light; Auto under a dark OS, and Dark, are the dark palette already, and are left alone. Night modes built on the Dark Reader library, Brave’s and Firefox’s on iPhone and iPad and the Dark Reader extension among them, take no notice of color-scheme and repaint every page, the dark palette included: run as Brave’s Night Mode runs it (Dark Reader 4.9.125, on an iPhone-sized page), Light painted #2b2815, a dark olive, and Dark #181919. They stand down for a page carrying <meta name="darkreader-lock">, which theme/head.hbs writes for every book, even when the lock arrives after they’ve started; with it, Light paints #fdfcf7 and Dark #111, and Auto follows the reader’s system, whatever the night mode is set to. A reader who wants the book dark picks Dark. Only Chromium’s Auto Dark Mode flag (chrome://flags), which forces every page dark while the browser itself is light, still darkens Light: it ignores both opt-outs, so no page can stop it.

The print override needs the class too

print.css resets layout and colours only links (#4183c4), and the browser drops backgrounds when printing — so without an override a reader on the dark palette (every reader, with THEME="dark") prints pale text onto white paper. The first @media print block in custom.css, which comes after the palette, forces ink-on-paper.

It has to name the theme classes to do it. A media query does not raise specificity, so @media print{ html{…} } is (0,0,1) and loses outright to the palette’s (0,1,1): it parses, it validates, and it silently never applies. Naming the palette’s own classes, html.ayu or (with "auto" and "picker") html.light,html.ayu, ties it at (0,1,1), and coming after the palette wins it. With "picker" it names html.js.light too, the Light choice’s (0,2,1) selector, which would otherwise outweigh it. Keep the print selector the same as the palette’s, not html.

Code prints in ink too. Its token colours come from the highlight sheet, the dark one whenever the reader’s palette is dark, which are pale on paper, and with background graphics on its ground would print dark: a rule after the palette prints code blocks in #111 on #f5f5f5, comments, and the tags in them (TODO:, @param), in #555, and a search hit in code on #f2d9a8. The admonitions print in the light palette’s accents, each 4.5:1 or more on paper.

Print ink stays #111 even though the screen palette softened its --fg to #191713. Butterick’s case for grey over black is about an emissive screen; paper reflects, and wants the contrast the other way.

Charter fonts

The theme is designed around Charter (Matthew Carter’s screen-and-print serif, released free by Bitstream). The script does not ship the font files — you add them, then re-run.

  1. Download the four woff2 weights. Matthew Butterick redistributes them at https://practicaltypography.com/charter.html (scroll to the download; the licence permits redistribution). You need regular, italic, bold, and bold-italic.

  2. Place them in src/fonts/ with these exact names:

    src/fonts/charter_regular.woff2
    src/fonts/charter_italic.woff2
    src/fonts/charter_bold.woff2
    src/fonts/charter_bold_italic.woff2
    
  3. Re-run the script (or, if you’ve hand-edited script-owned files and don’t want to re-run, just rebuild — the @font-face block is emitted by the script, so a re-run is the supported way to turn the fonts on).

All four must be present. If any is missing, the script warns, emits no @font-face, and the fallback serif renders — this is deliberate, because declaring faces whose files are absent 404s four requests on every page load. custom.css is served from the book root and CSS URLs resolve against the stylesheet, so fonts/charter_regular.woff2 finds the copy mdBook makes of src/fonts/charter_regular.woff2 (mdBook copies src/ verbatim and does not content-hash it).

Their line metrics are set in custom.css. A font file carries more than one set of line metrics: Windows reads one, other systems another. In these four the Windows set is right, 0.98em above the baseline and 0.236em below it; the other gives the descent with the wrong sign, 0.236em above the baseline. Read that way, each line of Charter sits 0.24em low in its box: a button’s label below its middle, the top bar’s title and icons low, and a line with inline code taller than the lines around it, since the code’s font, laid out right, reaches below Charter’s. Each @font-face sets the Windows set (ascent-override: 98%; descent-override: 23.6%; line-gap-override: 0%), which Chromium-based browsers and Firefox take on every system. Safari doesn’t take them yet: WebKit added them in August 2026, and until a Safari release carries them, it lays Charter out from the files as they are.

The sidebar masthead

SIDEBAR_MASTHEAD controls the band at the top of the sidebar, opposite the menu bar.

  • text (default) — sets BOOK_TITLE there in the menu-bar title’s own face, size, and baseline, left-aligned with the chapter list below it, and fades the menu-bar title out while the sidebar is showing so the name is never in two places at once. Needs no file: rename the book and the mark follows. (The title returns to the menu bar automatically when mdBook hides the sidebar — below 1080px, or on the b toggle — so the name is never nowhere.)
  • none — nothing in the sidebar; the book title stays in the menu bar as mdBook ships it.
  • image — masks src/logo.svg into the band, painted in the sidebar text colour so one file serves light and dark. Because it’s painted, the mask is an alpha mask: supply a flat silhouette (a monogram) on a transparent ground, or the painted background masks the whole band solid. You must add src/logo.svg; the script warns if it’s missing. The same file is also the favicon, which reads its fill rather than its alpha — see “The favicon.”

In text and image mode the masthead is sticky at the top of the sidebar the way the menu bar is sticky at the top of the page, and grows the same hairline once the table of contents scrolls under it (bound to the same colour mdBook uses for the menu bar’s own scrolled border, so the two lines match).

The favicon

theme/favicon.svg has two sources, and it is written whatever THEME and SIDEBAR_MASTHEAD are — a text masthead and a favicon are different jobs, and they coexist. src/logo.svg, whenever it exists, is copied to it verbatim and always wins. Only when there is no src/logo.svg does FAVICON_TEXT come into it, and then the value is drawn as letters: ink on a transparent ground, no plate, #191713 swapping to #e8e6da under prefers-color-scheme: dark — the two --fg values from the light and dark palettes. "auto", the shipped default, takes the first alphanumeric character of BOOK_TITLE, a letter as a capital, so the stock mybook draws an M and renaming the book renames the mark. Any other string is drawn as typed, case preserved, cut to the first three characters with a warning if you gave it more. "" draws nothing at all, and mdBook’s own bundled favicon stays.

Both inks are written whatever THEME is, a dark-only book included. The mark is drawn in the browser’s tab bar, not on the page, and the tab bar follows the reader’s browser or OS rather than the book, so a dark-only book still sits in a light tab bar for a reader whose system is light. Before v41 a dark-only book drew the pale ink alone, and in a light tab bar it all but vanished.

A drawn favicon is not in Charter, and it cannot be. A favicon is fetched as an image, and a browser gives an image no network of its own: @font-face, external stylesheets and external images are all blocked, and scripts do not run. So the four woff2 in src/fonts/ are unreachable from inside that file, and naming Charter first in its stack only helps a reader who happens to have Charter installed as a system font. Everyone else gets the next face down — Palatino, Noto Serif, Liberation Serif, Georgia, a generic serif. This is the same limit that forces the text masthead to be CSS text rather than an SVG, and it has no workaround short of converting the letters to outlines, which would undo the one thing "auto" is for. What does work inside that sandbox is the prefers-color-scheme media query, because the <style> is part of the file rather than something fetched: mdBook’s own bundled favicon.svg swaps its fill exactly this way.

If you supply src/logo.svg instead, give it a prefers-color-scheme fill-swap of its own. The same file is doing two unrelated jobs and they read it differently: the image masthead uses it as a CSS mask, so only its alpha matters and the sidebar paints the shape in --sidebar-fg; the favicon shows the file itself, so only its fill matters and nothing recolours it. A flat black silhouette therefore mastheads perfectly in both light and dark and then disappears into a dark browser tab. Put the swap in the file — a <style> block carrying @media (prefers-color-scheme: dark){ … } over your fill — and both jobs are served by the one asset, because the mask ignores the colour it can’t see and the favicon uses it.

Either source replaces mdBook’s bundled favicon.svg and drops its favicon.png with it. mdBook copies neither default once one of the pair is overridden, so the built site carries the SVG alone and the page emits a single <link rel="icon"> where it otherwise emits two. That is fine in Chrome, Edge and Firefox, which all render SVG favicons; a client that does not is left with no icon rather than falling back, because there is no longer a PNG to fall back to. If that matters to you, put your own theme/favicon.png beside the SVG and mdBook will ship both — it drops a default only when you override exactly one of them.

Finally, theme/favicon.svg is script-owned but never deleted — only overwritten. Removing src/logo.svg, or setting FAVICON_TEXT="", stops the file being regenerated; it does not remove the one already there, and the site goes on serving the last mark written. That is deliberate: a stale favicon does no harm beyond being out of date, and clearing it on every run would destroy a favicon you had put there by hand, which no re-run could give back. To actually clear it, clear both sources and then delete theme/favicon.svg by hand.

Headings and rules

A chapter’s title is one block: the H1, and the subtitle under it if there is one. One hairline closes the block, under whichever of the two comes last. The Last updated line the deployed site adds hangs under that rule, before the text, like a dateline.

Below the title, headings draw no rules. A --- divides the page, with space above and below it; written on the line straight after a heading, it underlines that heading instead:

## Setting up
---

Body text starts here.

Under a heading that has a subtitle, put the --- after the subtitle, with a blank line between them, and it underlines the two together. The blank line matters: the subtitle’s <p> starts an HTML block that runs to the next blank line, so a --- straight under it is printed as three dashes.

Don’t write one under the chapter’s H1: the title block has its rule already, and a --- there draws a second.

Subtitles

To hang a subtitle under a heading, put a marked paragraph on the line below it:

# Chapter One
<p class="mdb-subtitle">A subtitle that explains what this chapter is about</p>

Body text starts here.

mdBook passes block-level HTML through verbatim, so the <p> renders as-is. The starter chapter_1.md includes one as an example (delete the line if you don’t want one).

Under the chapter’s H1 the subtitle is part of the title block, and the block’s rule sits below it; on the deployed site the Last updated line follows under the rule (see “Headings and rules”). Under any other heading it is the muted italic alone; a --- after it, with a blank line between them, draws a rule under the pair.

One caveat: markdown syntax inside the <p> is not parsed. **bold** stays literal. If a subtitle needs emphasis or a link, write it as HTML (<em>, <a>). (If you need markdown-in-subtitle often, a <span class="mdb-subtitle"> on its own line does get its markdown parsed, but the span is inline and can’t take the block rule directly — the <p> form is the default for that reason.)

Code line numbers

With CODE_LINE_NUMBERS=true (default), a line-number gutter appears beside each code block that (a) had a language on its fence (```bash, not a bare ```) and (b) is ten lines or longer. Shorter blocks, and auto-detected blocks with no language on the fence, are left unnumbered.

The gutter is a sibling <div> next to <code>, never inside it — so <code>’s DOM is exactly what mdBook rendered, and mdBook’s copy button (which reads code.innerText) and a manual Ctrl+C both yield the exact source, numbers excluded. This replaced an earlier approach using the highlight.js line-numbers plugin, which rebuilt <code> as a table and corrupted copied text (a tab per line, a blank line per row).

Blocks with hidden lines (mdBook’s lines prefixed by a hash plus a space, tucked behind the eye toggle) and .playground blocks are skipped by design.

A ```rust block is plain code, not one of mdBook’s playgrounds: book.toml sets [output.html.playground] runnable = false. A playground has every reader’s browser ask play.rust-lang.org for its list of crates as soon as a page with one opens, before anyone clicks Run. To make one block runnable all the same, write ```rust,mdbook-runnable: its page, and print.html, then ask. Your own Markdown can still reach other sites, with a picture or a video embedded from one.

If you also set HEADING_NUMBERS=true, note the script configures mdbook-numbering with its own code numbering turned off — precisely because that feature injects the copy-breaking plugin. The relevant lines it writes into book.toml:

[preprocessor.numbering.code]
enable = false

The gutter above is the numbering you get.

Wide pages and wide blocks

The default measure is ~700px. Two markers reach past it, and they are not interchangeable.

A whole page. Drop this anywhere on it:

<div class="mdb-wide"></div>

Any element with class mdb-wide on a page widens that page’s content column to 1000px — prose along with everything else. Useful when most of the page is a wide table or a large diagram and the reading measure is beside the point.

One block, prose left alone. Wrap the block instead:

<div class="mdb-bleed">

```python
def pellentesque(habitant, morbi=None, tristique=(), senectus="netus", fames=0):
    return {k: v for k, v in sorted(habitant.items()) if k not in (morbi or ())}
```

</div>

The wrapped element breaks out to 1000px, centred on the text column, below any float that comes before it; everything else on the page sits exactly as it would on a page carrying no marker at all. On a screen narrower than that, it takes the content area’s width. That is the whole difference — mdb-wide moves the prose, mdb-bleed doesn’t. Nothing about it is code-specific: a wide table, an image or a diagram takes the same wrapper.

Two things to get right:

  • Keep the blank lines inside the wrapper. Without them the fence is read as raw HTML and renders as literal text, backticks and all. It is the one way to get this wrong, and it fails loudly, so you will see it on the page.
  • Keep the wrapper at the top level of the chapter, not nested inside a list item or a blockquote. The rule reaches direct children of <main> only.

On a page with both markers, mdb-wide widens everything to 1000px, and an mdb-bleed block has nothing to add.

Both are screen-only. The print view concatenates every chapter into a single <main>, so an mdb-wide marker anywhere would otherwise widen the whole printed book.

Neither is a cure for a genuinely long line. With the theme’s type and a typical monospace face, a code block fits roughly 62 characters at the measure with the line-number gutter on and about 68 without; bled to 1000px that becomes roughly 92 and 98. Past that the block scrolls sideways — mdBook’s own behaviour, and a cheap one, since a scrolling block neither breaks the layout nor pushes the page around. If you’re pasting a script whose lines run past about 90 characters, rewrapping the source is the only thing that genuinely helps.

Writing content

Chapters live in src/*.md and must be listed in src/SUMMARY.md — mdBook does not auto-discover files. In SUMMARY.md:

  • A link with no leading dash, listed before the numbered list, is a prefix chapter — renders unnumbered, above the numbered chapters. The starter about.md is one.
  • A link listed after the numbered list (same no-dash syntax) is a suffix chapter — unnumbered, below them. (The script doesn’t create one; add your own, e.g. a changelog, if you want it.)
  • A --- line draws a separator rule in the sidebar.
  • A # Title line draws a part heading in the sidebar.

Title, author and description (BOOK_DESCRIPTION) are in book.toml [book].

Pictures and media in raw HTML, in a chapter in a folder (src/guide/setup.md, say), show on that chapter’s page but not in print.html, the page the print button opens, where every chapter is laid out together. mdBook 0.5 builds print.html at the book’s root and corrects a chapter’s relative addresses there only on <a> and <img>, in href and src; anything else keeps the address that worked from the chapter’s folder: a <picture>’s or an <img>’s srcset, a <video> or <audio> (its src, poster and <source>s), an <iframe>, or a url() in a style attribute. For a picture that should print, use a Markdown image or a plain <img src>. Chapters at the top of src/ have no such problem, and printing a single page (Ctrl+p on it) prints its media whatever its folder.

Unlisted chapters

mdBook has no unlisted state of its own. It builds exactly what SUMMARY.md lists: a chapter that isn’t in the file isn’t rendered at all — the .md isn’t even copied into book/, since mdBook copies every other file in src/ verbatim but never markdown — and a chapter that is in the file gets a sidebar row, a place in the prev/next chain, and a section of print.html. So if you only want a page to exist in the repo and not on the site, leave it out of SUMMARY.md; mdBook ignores it silently, no warning and no output file.

To publish a page but keep it off the table of contents, do two things. Both are required:

  1. Put the file under src/unlisted/. That directory is reserved. Nothing goes in the page itself — no toggle, no front matter, no marker.
  2. List it in src/SUMMARY.md as a suffix chapter: a no-dash link placed after the numbered ones.
# Summary

[About](./about.md)

- [Chapter 1](./chapter_1.md)
- [Chapter 2](./chapter_2.md)

[Working notes](./unlisted/notes.md)

Step 2 is the one this feature invites you to skip, so it’s worth being blunt about. SUMMARY.md is mdBook’s only list of what to build. A markdown file that isn’t on it is not rendered, not copied and not reachable — and linking to it from another chapter does not publish it. mdBook rewrites the .md in your link to .html and hands you a 404 on a page it never generated, with no warning at build time and no error in the log. The tell is the output directory: mdBook creates book/unlisted/ while copying files but leaves it empty. If the page you expected isn’t in there, it isn’t in SUMMARY.md.

With both steps done, three things happen, all from generated files, so none of it is a hand edit you have to re-apply after a re-run:

  • custom.css hides the sidebar row. It’s keyed on the link target, because the row to hide sits on every other page, where a marker inside the unlisted page would be invisible. The rule covers both sidebars — the one toc.js injects and the toc.html iframe that readers with JavaScript off fall back to.
  • custom.js removes the prev/next links pointing into the directory, so → skips those chapters too. Hiding them wouldn’t be enough: mdBook’s book.js reads .nav-chapters.next straight out of the DOM and follows its href whether or not it’s painted. Links out of an unlisted chapter are untouched, so its own back arrow still works.
  • book.toml drops the whole directory from the search index with [output.html.search.chapter]. One directory key covers every chapter under it.

That last one is emitted only when SUMMARY.md already links into unlisted/ — and it has to be, because mdBook fails the build on a search.chapter key that matches no chapter (key `unlisted` does not match any chapter paths). So the order is: add the chapter, list it, then re-run. Forget to re-run and the page is merely searchable, not broken. The stanza is:

[output.html.search.chapter]
"unlisted" = { enable = false }

A sub-table ends the parent table, so it must come after every [output.html] key; the script puts it after them and [output.html.playground].

Two limits, both worth knowing before you rely on this:

  • List unlisted chapters last. They are suffix chapters, so they don’t renumber anything, but the arrow chain is linear: put one in the middle of the book and the chapter before it loses its next arrow while the chapter after it loses its prev arrow, leaving a hole you have to click around.
  • print.html still contains them. The print view is a flat concatenation of every chapter with no per-chapter element to select, so there is nothing to hide. Closing it would take a second marker convention in every unlisted page plus a rule that silently swallows the rest of the printed book whenever an unlisted chapter isn’t last — not a trade worth making for a view you reach by explicitly asking for the whole thing.

And the plain fact underneath: none of this makes a page private. src/ is in the repo, the repo is public (Pages needs it to be, on a free plan), and GitHub Pages serves the URL to anyone holding it. This is unlisted — off the contents, out of search, out of the arrow chain — not hidden.

The name is literal and reserved: src/unlisted/ and nothing else. A directory merely containing the word is fine — src/notunlisted/ is an ordinary chapter directory and stays fully visible.

Hiding part of a page

An HTML comment hides part of a page: <!-- … --> goes into the page as written, the browser doesn’t show it, and mdBook’s search index leaves it out. Two things to know:

  • On a line of its own, it ends what it’s in. Between two table rows it ends the table, and every row after it becomes a paragraph of pipes; between two lines of a paragraph, or two list items, it splits them in two. To park a row, put it after the last row, in a comment that says where it belongs; to hide words in a paragraph, put the comment after some text on its line.
  • It isn’t private. It’s in the page’s source (View Source), in print.html, and in the Markdown on GitHub.

To keep a whole page off the site, leave it out of SUMMARY.md (see “Unlisted chapters”).

Layout: a section at a time

LAYOUT decides how a chapter is shown: on one page, or one section at a time.

  • "scroll" (default): the chapter on one page.
  • "h2": one section at a time, each H2 starting one; its H3 to H6 stay in it.
  • "nested": one section at a time, every H2 to H6 starting one.
  • "picker": a button beside search, an open book, with which each reader chooses Scroll, the chapter on one page, or Pages, as "nested". Every reader starts on Scroll. The choice is kept in the reader’s browser, for this book only: books on one USER.github.io don’t share it. A choice made in one tab reaches the reader’s other open tabs at their next page.

It’s done in the reader’s browser. The book is built the same in every layout, one page per chapter, so its addresses, search, the link check and print.html are the same in all four, a switch of LAYOUT breaks no link or bookmark, and printing a page prints the whole chapter whatever the screen shows.

A section starts at a heading straight in the chapter. One inside a list, a quote or an HTML element such as <details> stays in the section around it, and so does one written in HTML without an id (<h2>…</h2>), which mdBook gives no anchor; written with one (<h2 id="setup">…</h2>), it starts a section, but the sidebar doesn’t list it. An H1 after the chapter’s first starts no section. What comes before the first heading is the first section; when that’s only the title (the H1, a subtitle, the Last updated line), with nothing else but rules, empty anchors, an mdb-wide marker, or a <style>, <script> or <link>, it joins the section after it. An empty anchor kept just above a heading (<a id="old-name"></a>, or the older <a name="old-name"></a>) goes with the heading.

One section at a time, with "h2", "nested", or Pages:

  • The arrows at the sides of the page, or at its foot on a narrow window, and the ← and → keys turn to the next or the previous section; past a chapter’s last section, to the next chapter’s first, and back from a chapter’s first, to the last section of the one before. Where mdBook draws no arrow (before the first chapter, after the last, or where the next chapter is unlisted), one is drawn while there’s a section to turn to. mdBook’s ? popup says what the keys do.
  • On a touch screen, the arrows turn the sections; a swipe doesn’t. The sidebar’s swipes work as before.
  • The sidebar marks the section shown, with "h2" by its H2, and a first section with no H2 (H3s before the chapter’s first H2) by none; in a chapter of one section, shown whole, the heading in view, as in Scroll. Its links, a search result, a link to an #anchor and Back each go to the section holding the place, and a search result’s match is highlighted there.
  • A section shows only the footnotes it cites, and those they cite in turn, and “Section 3 of 19” under it. The window’s title names the section, from the second on.
  • The Last updated line shows with the chapter’s first section.
  • The browser’s own find (Ctrl+F) searches only the section shown, and a link to a passage of text (#:~:text= and the passage, as search engines make) opens the chapter’s first section; one that also names the section the passage is in (#beta:~:text=…, as a browser’s “Copy link to highlight” makes in Pages) opens that section at the passage.
  • With HEADING_NUMBERS=true, each section’s heading shows its number.

On a phone, "picker"’s button takes room from the book’s title in the menu bar, which mdBook cuts short when it doesn’t fit: with THEME="picker", a repo and OFFLINE on, a phone 390px wide leaves the title about 110px with the button and 146px without.

Read offline

OFFLINE lets readers keep the book on their own devices, through one button in the menu bar, beside print.

  • "full" (default): the button’s panel offers both, one under the other, each a big button with a line under it saying what it’s for: Save for offline keeps the book in the reader’s browser, on a computer or a phone, and updates it when they ask; Download a copy is a copy to keep on any computer, online or air-gapped, with its version, size and SHA-256 checksum.
  • "download": Download a copy alone.
  • "none": no button.

./build makes what the button offers, here and in the deploy. So the button shows on the published site, and on a local server after ./build, where it offers only the download: saving needs the published site, over HTTPS. The preview doesn’t have the button: it builds into .preview/, without what ./build adds.

So the script’s run serves book/ beside the preview, on 127.0.0.1 at the first free port from 8000, and its last lines give the address: book/ has it, as ./build left it, at http://127.0.0.1:8000. Open it, and the cloud beside print opens the panel, with Download a copy alone. The page is the book as the run’s ./build made it, not as you edit it: ./build refreshes it, then reload the page. Ctrl-C stops it with the preview. With OFFLINE="none" the run serves the preview alone.

To check the button without a run of the script (with the preview re-launched, say), serve book/ yourself:

  1. Open a second terminal if the preview holds the one you’re in. (Or stop the preview there with Ctrl-C, and use that one.)

  2. In the book’s folder, run ./build, then:

    python3 -I -m http.server -b 127.0.0.1 -d book 8000
    

    It prints Serving HTTP on 127.0.0.1 port 8000 (http://127.0.0.1:8000/) ... and keeps running, with a line for each file a browser asks for. -I makes Python use its own server even when the folder you’re in has one named http. If it ends instead with OSError: [Errno 98] Address already in use, something else has port 8000, such as a run’s own server for book/: put 8001 in its place, here and in the address below.

  3. Open http://127.0.0.1:8000. The cloud beside print opens the panel, with Download a copy alone. Ctrl-C in that terminal stops the server, with Keyboard interrupt received, exiting.

Saving. Before a save, the panel shows Save for offline, and under it the version on the site, its build date and its size. Once the book is saved, its pages come from the saved copy, online or offline, with everything they load from the book’s own site, so a reader never gets parts of two versions. Pictures, videos or scripts a page loads from another site aren’t saved, here or in the download. When the site has a newer version, a dot appears on the button, and the panel shows the saved version and the latest, how many versions behind the saved one is, how many pages changed, were added and were removed, and how much there is to download, with Update to v13 and Remove saved copy, and each page the saved copy serves says so above its text: You're reading your saved copy, v12. v13 is out: update it from the ✓ above. Remove is offered only while the site answers: a reader who is offline, or on a network that can’t reach the site, keeps the copy they’re reading. A site that no longer has the book still answers, so its copy can be removed too. An update fetches only the files that changed, checks every file against the site’s checksums, and only then switches to the new copy, in one step; an update that fails or is interrupted leaves the saved copy as it was. Nothing downloads or changes until the reader presses a button: each page only reads the site’s version file, to see whether a newer version is out. The icon shows where things stand: a cloud before a save, a tick once saved, a plane while offline.

Versions. A version is a commit that changed what the book is built from: src/, theme/, book.toml, the workflow (it pins the releases the build uses, so a re-run that takes another one changes it) and the script-owned files beside them, not README.md. v12 is the twelfth. Its date is that commit’s time. A commit that changes nothing on the site leaves a saved copy up to date. This count is the offline button’s own: the version in the sidebar footer is the git tag (see “The version line”), a different number. A file a chapter includes from outside src/ ({{#include ../examples/a.rs}}) isn’t counted: a change to it alone changes the site within a version, so keep such files under src/ (mdBook also publishes any that aren’t Markdown).

Browsers. A save asks the browser to keep the copy even when the device runs short of space: Firefox asks the reader, Chrome and Safari decide on their own. Safari, on an iPhone, an iPad or a Mac, deletes what a site saved after seven days of Safari use in which the reader didn’t use the site. A book added to the Home Screen of an iPhone or iPad is a web app with storage of its own, which counts only its own days of use; the panel tells readers to add the book there first and save it from there.

The download. One file in offline/, named for BOOK_NAME and the version, with a .sha256 file beside it: mybook-v12.zip, the whole book in a folder, to keep on any computer, online or air-gapped, with a README.txt saying to open index.html, how to check the download’s checksum on Linux, macOS and Windows, and where newer versions are. It opens from disk in a browser, search included. The fonts are inside its stylesheets, and with SIDEBAR_MASTHEAD="image" so is the logo: Firefox won’t load a font from a folder above a page opened from disk, and Chromium and WebKit won’t load a mask image into one at all. A link to a folder of the book ([Setup](setup/)) points at the folder’s index.html, the page the site serves for it: as written, it would show the folder’s files from disk. Its stamp is its version’s, that commit’s time and SHA with no tag, so rebuilt from the same version on the same system it comes out the same to the byte. Inside it, the button shows which version it is, its build date, and the site’s address for newer versions (from GIT_REPO_URL).

Apart from the file names, which README.txt’s checksum commands use too, what readers see never names the format: the panel calls it a copy to keep on any computer, online or air-gapped, and says to unpack it and open index.html in the folder it makes. It’s for a computer: on a phone, readers save the book in the browser.

Turning it off. In every mode the site carries sw.js. Outside "full" it’s a worker that deletes a saved copy and then itself, so a reader who saved the book gets the site’s pages again on their next visit online, with nothing left behind.

What it adds. To the project: offline.py, run by ./build in every mode, and in "full" a manifest line in theme/head.hbs. To the site, besides sw.js: version.json (the version, its date, the version history and the download’s checksum, and in "full" every file’s size and SHA-256), and offline/ (the download and its .sha256 file). A file of yours in src/ by any of these names is written over, as is a README.txt at the top of src/ in the laptop copy. In "full", also manifest.webmanifest, so a book added to a home screen opens as an app of its own (on an iPhone or iPad, with its own storage: see “Browsers”). The app’s icon is the favicon, favicon.svg, which a browser draws at any size; a book whose favicon is a PNG alone has none in the manifest, and Chromium-based browsers then don’t offer to install it: they want an icon of 144 pixels or more. With favicon.svg, they take the book as an installable app from the first visit, before anything is saved, and offer to install it (on a computer, an install button in the address bar). And book.toml gets hash-files = false: mdBook’s fingerprinted file names (version-1a2b3c4d.js) put the deploy stamp and a hash of the search index into every page, so a download would change with every commit, and an update would fetch every page. Without them, a browser that cached a script or stylesheet just before a deploy can use it with the new pages until it next checks.

Size. GitHub Pages caps a published site at 1 GB, and the download adds the book once more, compressed where it compresses. Measured here, a 100-chapter text book grew from 12.1 MB to 15.0 MB, and a book of 100 photos from 32.9 MB to 63.6 MB: about twice, for pictures, which don’t compress. GitHub also sets a soft limit of 100 GB a month on what a Pages site serves, and each download serves the book again, as does each save in a browser (an update fetches only what changed): at about 30 MB a copy, as for that photo book, 1,000 downloads or saves come to about 30 GB.

Tools and time. offline.py needs only Python’s standard library. On the 100-chapter book, ./build took about 2.6 seconds, against 1.7 without the download.

custom.js adds:

  • b toggles the sidebar (mdBook ships no key for it); the shortcut is listed in mdBook’s own ? help popup.
  • Swipes open and close the sidebar on a touch screen: a quick swipe right that starts in the left quarter of the screen (300px at most) opens it, and a quick swipe left over the left 300px, where the sidebar is, closes it. Each has to go more across than up or down, so a flick to scroll that drifts sideways does neither. Not with two fingers, not from a code block, a table or a form field such as the search box, whether or not it’s wide enough to scroll sideways, and not from anything open over the page: the offline panel, the theme menu (THEME="picker"), the layout menu (LAYOUT="picker"), a picture mdBook has zoomed (or the dimmed screen around it), or mdBook’s ? help popup. mdBook 0.5.4 has these swipes, but its code sets the sidebar-visible class and not the checkbox the sidebar’s CSS reads, so the sidebar doesn’t move; custom.js does the same through that checkbox. On a device with a touch screen custom.css also turns off Chromium’s swipes back and forward through history on the book’s pages (overscroll-behavior-x). Chromium takes a long sideways swipe, quick or slow, as one of those, a sidebar swipe included: without the rule, a long swipe right would go back, and a long swipe left would go forward when there’s a page to go forward to. On a laptop with a touch screen that may stop its touchpad’s swipes back and forward there as well. The Back and Forward buttons, Alt+← and Alt+→, and the phone’s own edge gesture still work. See “On a phone.”
  • Off-site links open in a new tab (target="_blank" with rel="noopener noreferrer"); same-origin links are untouched.
  • A “back to the book” link on print.html, and closing the print dialog returns you to the page you came from — mdBook auto-opens that dialog on the print page and cancelling otherwise strands you on the concatenated whole-book page.
  • Code blocks that overflow sideways become keyboard-scrollable (tabindex="0", so Tab reaches them; while one has focus, ← and → scroll it instead of turning the page, as they otherwise do in mdBook), and only those — a block that fits takes no tab stop. Re-checked whenever a block changes size, a window resize or a sidebar toggle, once the layout has settled.
  • One section at a time, with LAYOUT at "h2", "nested" or "picker" (in Pages): ← and → turn the section first, and with "picker" a layout button sits beside search. See “Layout: a section at a time.”
  • The offline button beside print, and its panel, unless OFFLINE is "none". See “Read offline.”
  • With THEME="picker", the theme menu holds Auto, Light and Dark: the Rust, Coal and Navy rows are removed rather than hidden, so the arrow keys reach every row that remains. See “Light and dark.”

On a phone

How the book behaves on a phone, from mdBook 0.5.4’s book.js and CSS, with the swipes custom.js adds:

  • Sidebar. It starts closed on a window narrower than 1080px. The three bars open and close it, and so do swipes: a quick swipe right that starts in the left quarter of the screen opens it, and a quick swipe left across the sidebar closes it. A slow drag does neither, and neither does a flick that’s more up or down than across, a swipe with two fingers, or one that starts on a code block, a table, the search box, an open menu or a zoomed picture. (mdBook 0.5.4’s own swipes don’t move the sidebar; custom.js makes them work. See “Keyboard and links.”)
  • Chapter arrows. On a window 1080px wide or less, they sit at the bottom of the page instead of at its sides; with the sidebar open, up to 1380px. One section at a time (LAYOUT), they turn the sections first; a swipe doesn’t.
  • Back to the top. Tapping the middle of the menu bar, where the book’s name is, scrolls to the top; the icons on either side don’t.
  • Menu bar. It slides away as you scroll down and comes back when you scroll up. With the sidebar open, it stays.
  • Search. Opening search the first time downloads the book’s whole search index, which grows with the book (mdBook warns once it passes 10 MB). On a phone, over a slow connection, that’s a wait before the first results.

The offline button works the same on a phone; its panel fits the screen.

A page for readers

A chapter you can paste in to tell readers how to get around: save it as, say, src/using-the-site.md and list it in SUMMARY.md. mdBook draws each <i class="fa…"> as its icon, the same ones the menu bar uses. As printed, it fits a book with a repo (GIT_REPO_URL), THEME left at "picker" and OFFLINE at "full"; adapt it to yours:

  • Without a repo, drop the Pencil row, since there’s no pencil, and end the Cloud row’s last sentence at “which version you have”: a downloaded copy shows the site’s address only when the book knows its repo.
  • With OFFLINE="none", drop the Cloud row and the “Read offline” row.
  • With OFFLINE="download", make the Cloud row **Download a copy** gives you a copy of the book to keep on any computer, online or air-gapped. In a downloaded copy, it shows which version you have and the address to get a newer one. and drop the “Read offline” row: that table is about phones and tablets, and the download is for a computer.
  • With THEME at "auto" or "dark", drop the Paintbrush row: there’s no theme button.
  • With LAYOUT at "h2" or "nested", in the Navigation section make “move you to the previous and next chapter” read “move you to the previous and next section, and past a chapter’s last section to the next chapter”, and in the table under “On a phone or tablet” make “Previous or next chapter” read “Previous or next section”.
  • With LAYOUT="picker", add this row to the icon table after the Magnifying glass: | <i class="fas fa-book-open"></i> Open book | Switches the layout: **Scroll** shows the whole chapter on one page, **Pages** one section at a time, turned by the arrow buttons and the <kbd>←</kbd> <kbd>→</kbd> keys. Your choice is remembered in this browser. |; and in the Navigation section, after “move you to the previous and next chapter”, add “ (in Pages, the previous and next section first)“.
# Using the site
<p class="mdb-subtitle">The keys, icons and shortcuts for getting around</p>

## Navigation

The **sidebar** lists every chapter of the book. The **menu button** (three horizontal bars) at the top left opens and closes it, and so does the <kbd>b</kbd> key. On a touch screen, a quick swipe right that starts near the left side, a little in from the edge (phones often keep the very edge for going back), opens it, and a quick swipe left across the sidebar closes it. Click any chapter to open it. On a narrow window, a phone especially, the sidebar starts closed; the menu button brings it up.

The **menu bar** along the top holds the icons described below. Clicking the middle of the menu bar, where the book's name sits while the sidebar is closed, takes you back to the top of the page. With the sidebar closed, the menu bar slides out of the way as you scroll down; scroll up a little and it comes back.

The <kbd>←</kbd> and <kbd>→</kbd> keys move you to the previous and next chapter; the large arrow buttons do the same, at the sides of the page on a wide screen and at the bottom of the page on a narrow window or a phone. <kbd>↑</kbd> and <kbd>↓</kbd> scroll the page, and <kbd>PgUp</kbd>, <kbd>PgDn</kbd>, <kbd>Space</kbd> and <kbd>Shift</kbd>+<kbd>Space</kbd> scroll a whole screen at a time. Press <kbd>?</kbd> any time for the full list of keys.

Not the book's doing, but in case you didn't know: <kbd>Alt</kbd>+<kbd>←</kbd> and <kbd>Alt</kbd>+<kbd>→</kbd> (<kbd>⌘</kbd>+<kbd>[</kbd> and <kbd>⌘</kbd>+<kbd>]</kbd> on a Mac) move you back and forward through your browser history. If a link took you somewhere, <kbd>Alt</kbd>+<kbd>←</kbd> brings you back.

## On a phone or tablet

There's no keyboard, so here's what stands in for each key.

| To do this | On a computer | On a phone or tablet |
|------------|---------------|----------------------|
| Open or close the sidebar | <kbd>b</kbd> | Tap the three bars, or swipe right from near the left side (not the very edge) to open, and left across the sidebar to close |
| Previous or next chapter | <kbd>←</kbd> <kbd>→</kbd> | Tap the arrow buttons at the bottom of the page |
| Search | <kbd>s</kbd> or <kbd>/</kbd> | Tap the magnifying glass |
| Back to the top | Click the middle of the menu bar | Tap the book's name |
| Back to where you were | <kbd>Alt</kbd>+<kbd>←</kbd> | Your phone's back button or back gesture |
| Read offline | Click the cloud, then **Save for offline** | Tap the cloud, then **Save for offline**; on an iPhone or iPad, add the book to your Home Screen first (in Safari: Share, then Add to Home Screen) and save it from there |

## Menu bar icons

| Icon | What it does |
|------|--------------|
| <i class="fas fa-bars"></i> Three bars | Opens and closes the sidebar. Same as <kbd>b</kbd>. |
| <i class="fas fa-paintbrush"></i> Paintbrush | Switches the colours: **Auto** follows your device's light or dark setting, **Light** and **Dark** keep one. Your choice is remembered in this browser. |
| <i class="fas fa-magnifying-glass"></i> Magnifying glass | Opens the search bar. Same as <kbd>s</kbd> or <kbd>/</kbd>. |
| <i class="fas fa-pencil"></i> Pencil | Opens the source of this page on GitHub, so you can suggest a fix if you spot a mistake. |
| <i class="fas fa-print"></i> Printer | Lays the whole book out as one long page, for printing or saving as a PDF. To print only the page you're reading, press <kbd>Ctrl</kbd>+<kbd>p</kbd> (<kbd>⌘</kbd>+<kbd>p</kbd> on a Mac) instead. |
| <i class="fas fa-cloud-arrow-down"></i> Cloud | **Save for offline** keeps the book in this browser, to read without a connection. Once it's saved, the cloud becomes a tick, with a dot on it when a new version is out, and a plane while you're offline. **Download a copy** gives you a copy to keep on any computer, online or air-gapped. In a downloaded copy, it shows which version you have and the address to get a newer one. |

One icon lives outside the menu bar: hover over any code block and a <i class="fa fa-clipboard"></i> **copy** button appears at its top right. It copies the code exactly as written, ready to paste into the terminal.

The version line

The foot of the sidebar can show a build stamp reading <version> · updated <when> · <sha>, with the SHA linking to the commit on GitHub when the book knows its repo (GIT_REPO_URL, or an origin on github.com), and plain text otherwise. The <when> is relative — “3 hours ago”, “yesterday”, then the plain date once it is older than three days — with the exact time shown on hover; see “The last-updated line” for the formatter it shares with the per-page line. The time is the built commit’s, not the build’s, so rebuilding a commit stamps the same time and SHA; only the version can differ, when a tag has been added since. This is stamped by the deploy workflow, into version.js, not by the script — so:

  • On a local mdbook serve / build, nothing stamps it and the line does not appear at all. That’s expected; it’s not a bug. (The fields are literal __MDB_BUILD_*__ placeholders in version.js locally, and the JS shows a field only once it’s been substituted.)
  • On the deployed site, the updated time and short SHA always appear. The version is the git tag: git describe --tags names the tag on the built commit, or the nearest reachable tag plus a commit count — v1.2 on the tagged commit, v1.2+5 five commits later. So tagging once per release (not once per commit) still stamps a version on every build, and no build claims a tag it isn’t.

To cut a release tag:

git tag -a v0.2.0 -m 'v0.2.0' && git push origin v0.2.0

The workflow fires on tag pushes as well as branch pushes (tags: ['v*']), so the tag alone deploys and stamps, once step 4 of “Push to GitHub” lets tags deploy. Without that rule the tag’s run builds, then stops at its deploy job with Tag "v0.2.0" is not allowed to deploy to github-pages due to environment protection rules, and the live site keeps its last build; add the rule, then re-run the failed job from the Actions tab. A repo with no tag yet shows just the date and commit; add the first tag and the version appears from then on.

The last-updated line

Each chapter carries a Last updated <when> line under its title, set like a date line: italic, a little smaller than the text, and muted. It hangs under the title block’s rule, before the text. The time is the chapter source’s last commit, not the build’s: the workflow runs git log -1 over every file in src/, in UTC (TZ=UTC … --date=format-local:%Y-%m-%dT%H:%M:%SZ), so the pages don’t carry your time zone’s offset, and stamps the result into version.js as a {"src/chapter.md":"<time>"} object, and the page matches its own source against that object (via the edit link, falling back to the URL) and formats it. The home page is a copy of the first chapter and finds its source only through the edit link, so it carries the line only when the book knows its repo. One section at a time (LAYOUT), it shows with the chapter’s first section.

Like the footer, it is relative — rendered by one shared formatter: under an hour reads “just now”, then “N hours ago” for the rest of the same calendar day, then “yesterday”, “two days ago”, “three days ago”, and from four days out the plain local date (YYYY-MM-DD). It is computed once at load — no ticking clock — and the element’s title carries the exact ISO timestamp, so hovering shows the precision the words drop. A time in the future (clock skew) clamps to “just now”.

It is a deployed-site feature, for the same reason the footer is: the dates come from git history the workflow has and a local build doesn’t, so mdbook serve shows no line. print.html never carries one (it concatenates every chapter, so it has no single source), and a chapter the workflow couldn’t resolve a date for simply shows nothing rather than a guess. Style it through .content .mdb-updated in custom.css.

./build, in the book’s folder, builds the book and then runs lychee over it, and stops on any broken internal link or #anchor, listing each by the Markdown file and line it’s written on. The script runs it before it opens the preview and stops there on a break; run it yourself before each push. The deploy workflow runs the same ./build, so a break that slips through still stops the deploy rather than shipping, and the live site keeps its last good build.

./build

On a clean book it prints mdBook’s three INFO lines, then links: every internal link and anchor resolves, and exits 0; unless OFFLINE is "none", an offline: … line comes before it (see “Read offline”). When mdBook reports an error it stops after mdBook, with stopped: mdBook reported an error, above.: mdBook goes on past some errors and exits as if it hadn’t, so an {{#include}} whose file isn’t there (ERROR Error updating "{{#include missing.md}}") would otherwise go out as the directive’s own text. mdBook reads such a directive anywhere in a chapter, in a code span or a code block too: to quote one, put a backslash before its opening braces, and mdBook drops the backslash and leaves the rest as written. On a broken one it prints the same INFO lines, then the breaks, and exits 1:

src/chapter_1.md:165: #paragraph
    no such anchor on this page; did you mean #paragraphs?
src/chapter_1.md:165: ./gone.md
    there's no src/gone.md

stopped: 2 broken links or anchors, listed above. fix them, then run ./build again.

Each break takes two lines. The first says where the link is written: the file under src/, the line, and the link as you wrote it, so src/chapter_1.md:165: ./gone.md is line 165 of chapter_1.md. The second says what’s wrong: no such page (or a chapter that isn’t in SUMMARY.md), no such anchor on the page it points to, a link that climbs out of the book, or a chapter’s .md named in a src written in HTML (mdBook writes .html for .md in a Markdown link or picture and in an <a href>, but not in the src of an <img> or an <iframe> written in HTML). For an anchor, did you mean names a heading on that page whose anchor shares most of its words, when one does. A link that comes from another file through mdBook’s include directive is listed under the chapter that includes it, with no line number. A link is found on its line however it writes its address, each character other than a letter or a digit as itself or percent-encoded (my notes.md or my%20notes.md, café.md or caf%C3%A9.md), in a folder’s name or an anchor as in a page’s, and only where it leads to the page or anchor that’s missing, not to another page of that name in another folder. One written with a backslash escape or an HTML entity (notes\(old\).md, AT&amp;T.html), or with a bracket or a parenthesis in a folder’s name in a Markdown link not written <…>, is listed under its file with no line number. If lychee itself fails, ./build shows lychee’s own output instead. If ./build says bin/mdbook is missing or bin/lychee is missing, run the script: it installs both. If it says offline.py is missing, run the script and commit the file it writes.

The check is offline: it tests the book’s own pages and anchors and does not fetch outside addresses, so a dead link to another site doesn’t stop it. print.html (every chapter again) and 404.html (its <base href> points at the live site) are skipped as inputs; links to them are still checked. The usual causes of a break are a renamed or deleted chapter still linked from another, a #anchor whose heading text changed (mdBook makes the id from the heading’s words), or a relative path that works on disk but not once mdBook has laid out the site.

lychee is looked up and pinned like mdBook (see “Releases and updates”): the script installs its newest release that has been out a week into bin/, and the workflow installs the same bytes, each checked against the pinned digest first. ./build reads lychee’s report, so after a run that moves lychee to a newer release (the run’s releases: line names it), break a link on purpose and check that ./build still lists it by file and line. If ./build shows lychee’s own output instead, that release changed how lychee works. To go back to the one you had, put back the workflow from your last good commit and run with KEEP_PINS=true (see “Releases and updates”): the run that moved lychee has already rewritten the workflow, so KEEP_PINS alone would keep the new one.

What a push publishes

The repo is public (Pages needs it to be, on a free plan), and so is everything in it: every file, and every commit with its details. Four things there can say who you are:

  • Commits and tags. Each carries a name and an email, git’s user.name and user.email, and its time with your time zone’s offset (+0530); a commit’s .patch page on GitHub shows all three. Set both before the first run (git config --global user.name …, git config --global user.email …): the script makes the first commit only with an identity you’ve set, so git can’t fill in one it made up from your account’s full name and your machine’s name, and makes it in UTC (+0000), unless GIT_AUTHOR_DATE or GIT_COMMITTER_DATE gives its time with another offset, which the run then names (with the offsets +0530 (author) and +0000 (committer)). It prints the name and address it used, and the committer’s too when that’s someone else, as git’s author.* and committer.* settings or its GIT_AUTHOR_* and GIT_COMMITTER_* variables can make it; without both set, it makes none, and says how to make it once they are. GitHub gives you a private address for this, ID+USERNAME@users.noreply.github.com: Settings → Emails → Keep my email addresses private shows it. To make your own commits and tags in UTC too, add alias git='TZ=UTC git' to ~/.bashrc. UTC hides the offset, not the hours: commit times across weeks still show when you work. Before the first push, git log --format='%an <%ae> %ad, committed by %cn <%ce> %cd' lists what each commit says; to redo the first commit under another name, set user.name and user.email, and unset any author.name, author.email, committer.name or committer.email setting and any GIT_AUTHOR_* or GIT_COMMITTER_* variable, which git takes before them, and for the _DATE ones before the clock (git config --show-origin --get-regexp '^(author|committer)\.' lists those settings and the file each is in, and prints nothing when there are none); then run TZ=UTC git commit --amend --reset-author --no-edit, which changes its author and its committer both.
  • Your GitHub account. Its name is in the site’s address (USER.github.io) and in every link to the repo: the pencil, the footer’s commit link, README.md, the laptop copy’s README.txt. A custom domain doesn’t hide it while those links point at the repo. Only a separate account, with its own email and SSH key, keeps a book apart from your own.
  • BOOK_AUTHOR. It goes into book.toml, in the repo. No page shows it: mdBook 0.5.4’s pages don’t use it.
  • Files you add to src/. They’re published byte for byte, on the site and in the download. A photo can carry where it was taken, the camera’s serial number, and its time with your offset: exiftool -all= -overwrite_original FILE (package libimage-exiftool-perl) takes all of that out. An SVG from a drawing program can carry your name and paths from your machine: scour --remove-metadata --enable-comment-stripping -i in.svg -o out.svg (package scour) takes those out and keeps the prefers-color-scheme style “The favicon” asks for.

The pages’ Last updated times are in UTC, whatever offset your commits have, and so are the footer’s and the download’s.

Push to GitHub

One time:

  1. Create an empty repo on GitHub — no README, .gitignore, or license. An auto-initialized repo makes your first push a non-fast-forward, and it’ll be rejected.

  2. Push. If you set GIT_REPO_URL before running, origin (SSH) is already set, so just:

    git push -u origin main
    

    (use your DEPLOY_BRANCH if you changed it). If you left GIT_REPO_URL empty, add the remote first — git remote add origin git@github.com:user/repo.git — then re-run the script and commit, so the book picks the repo up from origin (the pencil, site-url and the footer’s commit link; see GIT_REPO_URL under “The toggles”).

  3. Enable Pages once, by hand: Settings → Pages → Source → GitHub Actions. The workflow does not enable Pages for you (it can’t: enabling Pages takes a token with more access than a workflow’s own, which this script won’t ask you to store).

  4. Let release tags deploy, once: Settings → Environments → github-pages. GitHub limits that environment to your default branch, so it lists one rule, main (or your DEPLOY_BRANCH). Click Add deployment branch or tag rule, set Ref type to Tag, enter v*, and click Add rule. If it says No restriction instead, there’s nothing to add. The workflow can’t do this for you either: changing an environment’s rules takes administration access to the repo, which a workflow’s token can’t be given.

After these two settings, every push to main (or your DEPLOY_BRANCH) and every v* tag deploys automatically.

The workflow runs with least-privilege tokens: the build job’s can only read the repo (contents: read), and only the deploy job’s can publish (pages: write and id-token: write), so no other repo permission changes are needed. Pages itself requires a public repo, or a paid plan for a private one; an org-owned repo also needs Pages allowed by org policy.

After setup, every change: edit → ./build → git add -A → git commit → git push, and the workflow rebuilds and publishes. To rebuild without a content change: Actions → the workflow → Run workflow. Your site is at https://USER.github.io/REPO/ — the link in your README.

Custom domain: set it in Settings → Pages → Custom domain (the first-party deploy honours it), or commit a CNAME file into src/ (mdBook copies non-markdown files from src/ to the site root verbatim).

Releases and updates

The script names no release. Each run asks GitHub for the newest stable release of mdBook, of lychee (the link checker ./build runs) and of the workflow’s three actions (actions/checkout, actions/upload-pages-artifact and actions/deploy-pages), and with HEADING_NUMBERS=true asks crates.io for mdbook-numbering’s newest version. It takes a release only once it has been out a week (RELEASE_WAIT_DAYS, near the top of the script), and for mdBook and lychee only once the file it downloads has been up that long: most hijacked releases are found and pulled within a week, so the wait keeps one off your machine and out of your deploys, with one gap: what an action runs is the commit its tag names on the day of the run, and a tag can be moved to another commit long after its release came out, with no new release. A run refuses a moved tag only where the workflow already pins it (below); a tag it doesn’t pin yet, as on a first run, is taken as the run finds it. Stable means a plain version number that GitHub doesn’t mark as a pre-release or a draft; newest means the highest number, not the latest date, so a fix to an older series doesn’t win over a newer series.

mdBook is held to its 0.5 series: the theme and custom.js are written for 0.5’s pages. Once a newer series is out, the run says so and goes on with the newest 0.5 (mdBook v0.6.0 is out, but this script's theme and scripts are written for mdBook 0.5: using v0.5.4); a later version of the script moves to the new series.

Then it pins what it took. mdBook’s and lychee’s downloads are checked against the SHA-256 digest GitHub computed when each file was uploaded, and that digest goes into the workflow with the version, where CI checks its own download against it, and into bin/ beside the binary. A file uploaded before GitHub began computing digests, in June 2025, has none (mdBook v0.4.40’s, for one): the run then checks the download against the workflow’s digest when the workflow already pins that release, and otherwise pins the download’s own and says so (WARN: GitHub has no digest for …). A digest in a form the script doesn’t know (GitHub’s are sha256: and 64 hexadecimal digits) stops the run before anything is downloaded, unless it’s for the release the workflow pins, whose download is checked against the pin: GitHub gives the digest of … in a form this script doesn't know, so the download can't be checked; on a re-run, the message adds that KEEP_PINS=true keeps what the workflow pins till then. Each binary is run before it goes into bin/: they’re the gnu Linux builds, which need a glibc with every symbol version they link (mdBook 0.5.4’s needs 2.34, lychee 0.24.2’s 2.39), and on an older system the run stops there, naming the glibc it needs, with that tool’s binary in bin/ as it was (see “Other platforms”). An action is pinned by the commit its release’s tag names, with the release after it as a comment (uses: actions/checkout@<commit> # v7.0.1): a tag can be moved to another commit, and a commit can’t change. mdbook-numbering is pinned by version: crates.io never changes a published version’s files, and cargo checks them. Before it asks, the run says looking up the newest releases on GitHub (on GitHub and crates.io with HEADING_NUMBERS=true), so a slow network doesn’t look like a hang; after, it prints what it took:

releases: mdBook v0.5.4, lychee 0.24.2; v7.0.1, v5.0.0, v5.0.1 of the actions

No newer mdBook, lychee, mdbook-numbering or action reaches your published book until you re-run the script and push. The runner still moves with GitHub (ubuntu-latest). A re-run stops before it downloads or writes anything when something it pinned has changed since:

  • A release’s file has changed. GitHub’s digest for the mdBook or lychee release the workflow pins isn’t the pinned one, so the file was replaced after it was pinned: GitHub's digest for mdBook v0.5.4 is …, but the workflow pins … for it. Check the release’s page on GitHub before trusting it. To go on, delete the line with the pinned digest, which the message names (echo "… mdbook.tar.gz", or lychee.tar.gz), from .github/workflows/deploy.yml and run the script again: the tool is then looked up as on a first run, and like any file the new one is taken once it has been up a week. Until the workflow that run writes is pushed, each deploy stops at the workflow’s checksum check.
  • An action’s tag has moved. The tag the workflow pins for an action names another commit now: actions/checkout's tag v7.0.1 now names commit …, but the workflow pins … for it. Check the release’s page; to go on, delete that action’s uses: line from the workflow and run again: the action is then looked up as on a first run.

A release the workflow pins that GitHub no longer offers as a stable release out a week (pulled, marked a pre-release, its file removed, or its file uploaded again with the same bytes) isn’t refused: the run takes the newest one that is. When that’s older than the pinned one, the run says so (WARN: the workflow pins mdBook v0.5.4, which GitHub doesn't offer now as a stable release out 7 days …): if the pinned release was pulled or its file removed, the project’s releases page or its security advisories on GitHub may say why. When a newer release has been out a week, the run moves to it without a WARN, as any re-run would. The same goes for an mdbook-numbering version yanked from crates.io. Until you re-run the script and push, each deploy fails if the mdBook or lychee release the workflow pins was pulled or its file removed, since CI can’t download it, or if its mdbook-numbering version was yanked, since cargo won’t install a yanked one. A run with KEEP_PINS, or with GitHub out of reach, doesn’t ask about the pinned release, so one that has to download it (a fresh clone, or a workflow put back from an older commit) stops there if the release was pulled or its file removed (curl says The requested URL returned error: 404); a run that can ask GitHub, without KEEP_PINS, takes the newest that qualifies.

A newer release whose file isn’t there, or was uploaded again less than a week ago, is passed over too, and the run says so (WARN: mdBook vX.Y.Z is out, but has no mdbook-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz uploaded 7 days ago or more: using …). A file uploaded again is taken once it has been up a week; for a release that names its files differently, the script needs the new name. A release whose archive is laid out differently stops the run after its download, before the workflow is rewritten (no mdbook at the top of …, could not take lychee out of …): when the book’s own workflow pins another release of that tool, the message adds that KEEP_PINS=true keeps that one till a newer script knows the layout; on a first run, a clone’s included, there’s no way past it till then.

A download that doesn’t match GitHub’s digest stops the run too, before that tool is installed. The run installs mdBook, then lychee, then builds mdbook-numbering, and writes the workflow last, so when a later step fails, bin/ can already hold a new mdBook (and lychee) while the workflow still pins the old ones, until a run gets through.

None of the script’s own requests reads a config file, and none waits for ever: curl gives up after its time limits, git ls-remote when a transfer stalls for a minute, or within five minutes when a connection can’t be set up. curl runs with -q, so ~/.curlrc doesn’t apply, and git ls-remote runs without git’s system, global and repository config, so no setting there can turn its TLS checks off, send it to another address (url.<base>.insteadOf) or make it trust another certificate. Both still take the environment’s proxy and certificate settings (not GIT_SSL_NO_VERIFY, which the script clears): behind a proxy, set https_proxy; behind one that inspects TLS, add its certificate to the system’s certificate store, or name it in CURL_CA_BUNDLE for curl and GIT_SSL_CAINFO for git. A proxy or certificate named only in ~/.curlrc or git’s config doesn’t reach them. A proxy that lets only listed hosts through needs api.github.com, github.com and release-assets.githubusercontent.com (with HEADING_NUMBERS, also the crates.io and Rust hosts “Run it” lists); curl reports a refusal there as The requested URL returned error: 403, the line GitHub’s limit gives too.

GitHub answers 60 requests an hour from one address, and a run makes five, so behind an address many people share (an office, a VPN) the limit can run out: try again within the hour. No token is sent or needed, since everything the script asks is public. A re-run that can’t ask GitHub keeps what the workflow pins and says so (WARN: could not look up rust-lang/mdBook's releases (…): keeping mdBook v0.5.4, which the workflow pins); a release the workflow doesn’t pin, or pins without its digest (the line deleted after a refusal), stops the run, as on a first run, with nothing changed. Until the book has a commit, a run counts as a first run whatever the workflow pins: a clone’s first run, whose workflow is the reference repo’s, and a re-run after a first run that made no commit (no identity set). Without KEEP_PINS it keeps nothing the workflow pins, refuses nothing against it, and offers no KEEP_PINS way on. With HEADING_NUMBERS, crates.io out of reach works the same way for mdbook-numbering, so the first run with it on stops too.

  • Update: re-run bash bootstrap-mdbook.sh. It looks the releases up again and re-pins the workflow, so a new release reaches your book once it has been out a week, at your next re-run and push. A re-run regenerates all script-owned files from the toggles, so use this only if you haven’t hand-edited them.
  • Change a toggle without updating: KEEP_PINS=true bash bootstrap-mdbook.sh installs and pins exactly what the workflow already pins, each download checked against the pinned digest, without asking GitHub about any of it; an action pinned by its commit alone, as v53 pinned them, is kept as it is, and one with a comment that isn’t its release’s tag (# v7) keeps its commit, written back without the comment (the releases: line names it commit 3d3c42e5aac5). It trusts the workflow as it is, checking no action’s commit: use it on a workflow from your own commits, since a commit pinned there, even one from a fork, runs as the action. What the workflow doesn’t pin yet is looked up as usual, and the run says so: mdbook-numbering when you turn HEADING_NUMBERS on (KEEP_PINS: nothing to keep for mdbook-numbering in the workflow; looking it up). A workflow pinning an mdBook outside the 0.5 series stops the run with nothing changed: run without KEEP_PINS. It’s also the way back from a new release that breaks something, such as a lychee that ./build can’t run or actions that fail the deploy: put back the workflow from your last good commit, then run with KEEP_PINS=true. git log --oneline -- .github/workflows/deploy.yml lists the commits that changed the workflow, newest first, each as a short hash and its message; git checkout HASH -- .github/workflows/deploy.yml, with the hash of the last good one, puts that version back and prints nothing. The run’s releases: line then ends (KEEP_PINS: those the workflow pins, kept). A run without it takes the newest releases again, so keep adding it until a newer release, or this script’s next version, fixes the break.
  • Keeping hand edits: take a release by hand. From the release’s page on GitHub (for lychee, a release tagged lychee-v, not lychee-lib-v), download the file ending x86_64-unknown-linux-gnu.tar.gz, check that its sha256sum is the digest GitHub shows beside it, take the binary out into bin/ (tar -xzf mdbook-*.tar.gz -C bin mdbook; for lychee, tar -xzf lychee-*.tar.gz -C bin --strip-components=1 lychee-x86_64-unknown-linux-gnu/lychee), and set the version and the digest in the workflow’s install step to match. An action’s commit is the hash git ls-remote https://github.com/actions/REPO refs/tags/TAG 'refs/tags/TAG^{}' prints on its ^{} line, or on its only line when there’s no ^{} one. Write the release’s tag after the commit (uses: actions/REPO@COMMIT # TAG): a later run checks that the tag still names that commit.

Other platforms

Linux x86_64 only, as shipped. Three places name the platform, and the digest check couples them: install_mdbook’s triple="x86_64-unknown-linux-gnu", which names the file it downloads and installs as bin/mdbook; the file name lookup_releases asks GitHub about, whose digest the download is checked against and the workflow pins; and the workflow’s install step, which downloads the Linux tarball (the runner, ubuntu-latest, is x86_64 Linux) and checks it against that digest with sha256sum -c. lychee is the same, in install_lychee, lookup_releases and the workflow.

They’re the gnu builds, which use the system’s glibc and need one with every symbol version they link: mdBook 0.5.4’s needs 2.34, and lychee 0.24.2’s 2.39, so Debian 12, with 2.36, can’t run that lychee, and Ubuntu 24.04, with 2.39, can. Later releases can need newer. The run checks that each one runs before it goes into bin/, and on an older system it stops there (on a first run, before the book’s git repository is made), naming the glibc it needs, with bin/ keeping the binary it had: lychee 0.24.2's Linux build needs glibc 2.39 or later, and this system has 2.36: it wasn't installed, and bin/ keeps the lychee it had, if any. Run this on a system with glibc 2.39 or later. The way on is a newer system; the runner moves with GitHub. On a re-run where bin/ still has the release the workflow pins, as the binary itself says, the message adds the other, if KEEP_PINS would keep that release (its digest pinned too, and for mdBook a 0.5 release): KEEP_PINS=true keeps lychee …, which the workflow pins and bin/ has. The downloads are unpacked and run in bin/, not in /tmp, and so are rustup’s installer and, with HEADING_NUMBERS, cargo’s build of mdbook-numbering, so a system that mounts /tmp without the right to run programs (noexec) installs them too. A book on a filesystem mounted that way stops the run at the first download, which says so: mdBook v0.5.4 can't run from the book's bin/: the filesystem … is on doesn't allow running programs (it's mounted noexec). Put the book elsewhere, such as in your home folder.

So a triple can’t be swapped on its own. Change triple= alone and the download fails its check (… isn't the file it should be), since it isn’t the file whose digest the run looked up; change the file name in lookup_releases too and the run goes through, but pins the other platform’s digest, which CI’s check of the Linux tarball then fails. On another platform, install bin/mdbook and bin/lychee for the preview from that platform’s tarballs by hand (see “Keeping hand edits” under “Releases and updates”), and keep the workflow’s install steps at the Linux tarballs and their digests.

The real 0.5.4 asset triples, for a bin/mdbook installed by hand: Intel macOS x86_64-apple-darwin, Apple Silicon aarch64-apple-darwin, ARM Linux aarch64-unknown-linux-musl (musl, not gnu); Windows ships a .zip (x86_64-pc-windows-msvc). Always confirm the current spelling on the mdBook releases page.

Bootstrap-mdbook script

The script scaffolds an mdBook book, installs and version-pins the mdBook binary, writes a first-party GitHub Pages deploy workflow, and opens a live preview. For more details, see Publish a book or a knowledge base.

#!/bin/bash
# bootstrap-mdbook.sh, v63
#
# Bootstraps an mdBook book at PROJECT_DIR/BOOK_NAME with a GitHub Pages workflow
# that pins mdBook, lychee and the actions, then opens a live preview. Re-running
# updates it: the script-owned files regenerate; src/ and README.md are yours.
# Linux x86_64 only. How to use it: mdb-guide.md.
#
#   bash bootstrap-mdbook.sh                 create or update the book, check it, preview it
#   KEEP_PINS=true bash bootstrap-mdbook.sh  the same, with the releases the workflow pins
#   ./build                                  in the book: build it; stop on a broken link
#
# After the first push: Settings -> Pages -> Source -> "GitHub Actions", once.

set -euo pipefail

## ─── user configuration ──────────────────────────────────────────────────
# Each setting: mdb-guide.md, "The toggles". A re-run applies what changed here.

PROJECT_DIR="$HOME/Desktop"               # the folder the book's folder goes in; ~ allowed
BOOK_NAME="mybook"                        # the book's folder: letters, digits, . _ -
BOOK_TITLE="mybook"                       # the title readers see
BOOK_AUTHOR="John Doe"
BOOK_DESCRIPTION=""                       # a line about the book, for search engines;
                                          # "" = none
DEPLOY_BRANCH="main"                      # pushes here deploy; git's default branch
HEADING_NUMBERS=false                     # true: numbered headings (mdbook-numbering)
SIDEBAR_NUMBERS=true                      # sidebar chapter numbers (1., 1.1.)
THEME="picker"                            # "picker" (default): a button for Auto, Light
                                          # and Dark, starting on Auto; "auto": light or
                                          # dark as the reader's system is set; "dark":
                                          # the dark palette for every reader
GIT_REPO_URL=""                           # https://github.com/user/repo; "" = the book's
                                          # origin remote, if that is on github.com
CODE_LINE_NUMBERS=true                    # numbers language-fenced blocks of 10+ lines
SIDEBAR_MASTHEAD="text"                   # "text", "none", or "image" (src/logo.svg)
FAVICON_TEXT="auto"                       # "auto" (the title's first letter), up to 3
                                          # characters, or ""; src/logo.svg wins
LAYOUT="scroll"                           # "scroll" (default): a chapter is one page;
                                          # "h2": one H2 at a time, with its H3-H6;
                                          # "nested": every H2-H6 a section of its own;
                                          # "picker": a button for Scroll or Pages
                                          # (nested), starting on Scroll
OFFLINE="full"                            # "full" (default): saving the book in the
                                          # reader's browser, and a copy to download
                                          # for any computer, online or air-gapped;
                                          # "download": the copy only; "none": neither
# The footer's version label comes from git, stamped by the workflow: the tag on
# the built commit, or the nearest one plus a count (v1.2+5). Tag a release with
#   git tag -a v0.2.0 -m 'v0.2.0' && git push origin v0.2.0

## ─── settings check ──────────────────────────────────────────────────────
# An older settings block stops the run before anything is read or written.
check_settings() {
    local add="" del="" line name missing=false old value note=""
    while IFS= read -r line; do
        case $line in
            [A-Z]*=*) name=${line%%=*}; missing=false; [ -n "${!name+x}" ] || missing=true ;;
        esac
        # THEME replaces v53's four theme settings; its line gets the value they asked for.
        if [ "$missing" = true ] && [ "${line:0:6}" = THEME= ] && [ -n "${THEME_MODE+x}${LIGHT_THEME+x}" ]; then
            case "${THEME_MODE-fixed}/${LIGHT_THEME-false}" in
                */false) value=dark ;;
                default/*) value=picker ;;
                *) value=auto ;;
            esac
            line=$(printf '%-42s%s' "THEME=\"$value\"" "${line:42}")
        fi
        # LAYOUT takes the place of SPLIT_HEADINGS, done in the browser.
        if [ "$missing" = true ] && [ "${line:0:7}" = LAYOUT= ] && [ -n "${SPLIT_HEADINGS+x}" ]; then
            case "$SPLIT_HEADINGS" in
                h2|nested) value=$SPLIT_HEADINGS
                    note="SPLIT_HEADINGS=\"$SPLIT_HEADINGS\" made a page of each section; LAYOUT=\"$value\" shows them one at a time on the chapter's own page, so the split-off pages' addresses (chapter/section.html) stop working: a bookmark or a link from another site to one gets the 404 page." ;;
                # With HEADING_NUMBERS, v55 wrote no redirect pages: nothing is lost.
                *) value=scroll
                    [ "${HEADING_NUMBERS:-false}" = true ] \
                        || note="./build no longer writes the redirect pages at the addresses a split gives each heading (chapter/section.html): if this book was ever split, a bookmark or a link from another site to one gets the 404 page." ;;
            esac
            [ -z "$note" ] || note+=" A link in the book written to one (chapter/section.md) now stops ./build: write it to the chapter and the heading's anchor (chapter.md#section)."
            line=$(printf '%-42s%s' "LAYOUT=\"$value\"" "${line:42}")
        fi
        [ "$missing" = false ] || add+="    $line"$'\n'
    done <<'EOF'
PROJECT_DIR="$HOME/Desktop"               # the folder the book's folder goes in; ~ allowed
BOOK_NAME="mybook"                        # the book's folder: letters, digits, . _ -
BOOK_TITLE="mybook"                       # the title readers see
BOOK_AUTHOR="John Doe"
BOOK_DESCRIPTION=""                       # a line about the book, for search engines;
                                          # "" = none
DEPLOY_BRANCH="main"                      # pushes here deploy; git's default branch
HEADING_NUMBERS=false                     # true: numbered headings (mdbook-numbering)
SIDEBAR_NUMBERS=true                      # sidebar chapter numbers (1., 1.1.)
THEME="picker"                            # "picker" (default): a button for Auto, Light
                                          # and Dark, starting on Auto; "auto": light or
                                          # dark as the reader's system is set; "dark":
                                          # the dark palette for every reader
GIT_REPO_URL=""                           # https://github.com/user/repo; "" = the book's
                                          # origin remote, if that is on github.com
CODE_LINE_NUMBERS=true                    # numbers language-fenced blocks of 10+ lines
SIDEBAR_MASTHEAD="text"                   # "text", "none", or "image" (src/logo.svg)
FAVICON_TEXT="auto"                       # "auto" (the title's first letter), up to 3
                                          # characters, or ""; src/logo.svg wins
LAYOUT="scroll"                           # "scroll" (default): a chapter is one page;
                                          # "h2": one H2 at a time, with its H3-H6;
                                          # "nested": every H2-H6 a section of its own;
                                          # "picker": a button for Scroll or Pages
                                          # (nested), starting on Scroll
OFFLINE="full"                            # "full" (default): saving the book in the
                                          # reader's browser, and a copy to download
                                          # for any computer, online or air-gapped;
                                          # "download": the copy only; "none": neither
EOF
    for old in THEME_MODE LIGHT_THEME PREFERRED_LIGHT PREFERRED_DARK SPLIT_HEADINGS; do
        [ -z "${!old+x}" ] || del+="    $old"$'\n'
    done
    [ -n "$add$del" ] || return 0
    {
        echo "ERROR: the settings at the top of this script are from an older version of it, so nothing has been changed."
        [ -z "$add" ] || printf 'Add these lines to them:\n%s' "$add"
        [ -z "$del" ] || printf 'Delete these, which this version doesn'"'"'t read:\n%s' "$del"
        [ -z "$note" ] || printf '%s\n' "$note"
        echo "Then run it again. Each setting: mdb-guide.md, \"The toggles\"."
    } >&2
    exit 1
}
check_settings

# Expand a leading ~ (bash doesn't, inside quotes) and make the path absolute.
PROJECT_DIR="${PROJECT_DIR/#\~/$HOME}"
[ "${PROJECT_DIR:0:1}" = "/" ] || PROJECT_DIR="$PWD/$PROJECT_DIR"
BOOK_DIR="$PROJECT_DIR/$BOOK_NAME"

## ─── internals ───────────────────────────────────────────────────────────

# Releases are taken once out this many days: most hijacked ones are pulled within a week.
RELEASE_WAIT_DAYS=7

MDBOOK_VERSION=""
MDBOOK_SHA256=""
MDBOOK_DIGEST=""        # GitHub's digest; "-": it has none
LYCHEE_VERSION=""
LYCHEE_SHA256=""
LYCHEE_DIGEST=""
NUMBERING_VERSION=""
CHECKOUT_USES=""        # actions/checkout@<commit> # <tag>, for the workflow
UPLOAD_USES=""
DEPLOY_USES=""
SITE_URL=""
PAGES_URL=""

TMP_DIR=""
STATIC_PID=""           # the server for book/, beside the preview
# That server goes too: a background job ignores the Ctrl-C that stops the preview.
cleanup() {
    [ -z "${STATIC_PID:-}" ] || kill "$STATIC_PID" 2>/dev/null || true
    [ -n "${TMP_DIR:-}" ] && rm -rf "$TMP_DIR"
    return 0
}
# Downloads unpack in bin/, not /tmp: they run before they're installed, and /tmp is often noexec.
make_tmp() { [ -n "${TMP_DIR:-}" ] || TMP_DIR=$(mktemp -d "$BOOK_DIR/bin/.tmp.XXXXXX"); }
trap cleanup EXIT

say()  { echo "$*"; }
warn() { echo "WARN: $*" >&2; }
die()  { echo "ERROR: $*" >&2; exit 1; }

# Escape for a double-quoted TOML, JS or CSS string: backslash first, then ".
esc_dq() {
    local s="$1"
    s="${s//\\/\\\\}"
    s="${s//\"/\\\"}"
    printf '%s' "$s"
}

# Escape for XML text, & first; an unescaped & in a replacement would put the match there.
esc_xml() {
    local s="$1"
    s="${s//&/\&amp;}"
    s="${s//</\&lt;}"
    s="${s//>/\&gt;}"
    printf '%s' "$s"
}

# GIT_REPO_URL empty: read it from a github.com origin, so a fresh copy keeps the book's links.
derive_repo_url() {
    [ -z "$GIT_REPO_URL" ] || return 0
    [ -e ".git" ] || return 0
    local url rest
    url=$(git remote get-url origin 2>/dev/null) || return 0
    case "$url" in
        git@github.com:*)       rest="${url#git@github.com:}" ;;
        ssh://git@github.com/*) rest="${url#ssh://git@github.com/}" ;;
        https://github.com/*)   rest="${url#https://github.com/}" ;;
        *) warn "GIT_REPO_URL is empty and origin ($url) isn't a github.com remote this script reads; set GIT_REPO_URL to get the edit links, site-url and commit links"
           return 0 ;;
    esac
    rest="${rest%/}"
    rest="${rest%.git}"
    repo_path_ok "$rest" || { warn "GIT_REPO_URL is empty and origin ($url) doesn't read as user/repo; set GIT_REPO_URL"; return 0; }
    GIT_REPO_URL="https://github.com/${rest}"
    say "GIT_REPO_URL is empty; using origin: ${GIT_REPO_URL}"
}

# site-url lets the 404 page find its assets at any depth; user.github.io publishes at the root.
derive_pages() {
    SITE_URL=""
    PAGES_URL=""
    [ -n "$GIT_REPO_URL" ] || return 0
    local rest user repo
    rest="${GIT_REPO_URL#https://github.com/}"
    user="${rest%%/*}"
    repo="${rest##*/}"
    if [ "$repo" = "${user}.github.io" ]; then
        SITE_URL="/"
        PAGES_URL="https://${user}.github.io/"
    else
        SITE_URL="/${repo}/"
        PAGES_URL="https://${user}.github.io/${repo}/"
    fi
}

## ─── orchestration ───────────────────────────────────────────────────────

main() {
    # git as the book's folder has it, whatever GIT_DIR or GIT_CONFIG this terminal sets.
    unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG
    [ $# -eq 0 ] || die "this script takes no arguments; got: $*"
    validate_config
    check_build_file
    # The tools first: a run that stops for them has written nothing.
    ensure_tools
    check_sudo
    check_repo
    check_book_writable
    # Releases before the book is touched: a run that stops here has changed nothing.
    lookup_releases
    check_bin_writable
    mkdir -p "$BOOK_DIR/bin"
    cd "$BOOK_DIR"
    # The binaries before git: a run that stops at one leaves no repository.
    install_mdbook
    install_lychee
    init_git_repo
    ensure_git_remote
    derive_repo_url
    derive_pages
    write_book_files
    write_theme
    write_custom_css
    write_custom_js
    write_version_js
    write_gitignore
    write_readme
    write_build
    retire_split_headings
    write_offline
    if [ "$HEADING_NUMBERS" = true ]; then
        ensure_cargo
        ensure_mdbook_numbering
        ensure_numbering_config
    fi
    write_workflow
    git_initial_commit
    # The check CI runs, before the preview: a broken link stops the run here.
    ./build || { cargo_note >&2; exit 1; }
    # The preview builds into .preview/, so book/ keeps the offline files ./build added;
    # with OFFLINE on, book/ is served beside it, for the offline button.
    local port static="" both=""
    if [ "$OFFLINE" != none ]; then
        static=$(pick_port 8000)
        # -I: nothing in the book's folder can stand in for http.server.
        python3 -I -m http.server -b 127.0.0.1 -d book "$static" >/dev/null 2>&1 &
        STATIC_PID=$!
        both=" both"
    fi
    port=$(pick_port 3000)
    say ""
    say "before each push: ./build builds the book into book/ and stops on a broken link or anchor."
    say "first deploy: push, then set Settings -> Pages -> Source -> \"GitHub Actions\" once."
    case "$OFFLINE" in
        full) say "the preview has no offline button; book/ has it, as ./build left it, at http://127.0.0.1:${static} (the download alone: saving needs the published site). that page doesn't follow your edits: ./build refreshes it." ;;
        download) say "the preview has no offline button; book/ has it, as ./build left it, at http://127.0.0.1:${static}. that page doesn't follow your edits: ./build refreshes it." ;;
    esac
    cargo_note
    say "done. starting preview at http://127.0.0.1:${port} (Ctrl-C to stop$both); it rebuilds into .preview/ as you edit."
    say "re-launch it later with: cd \"$BOOK_DIR\" && ./bin/mdbook serve --open -n 127.0.0.1 -p ${port} -d .preview"
    say "run a second book alongside this one by giving it a different -p port."
    say ""
    ./bin/mdbook serve --open -n 127.0.0.1 -p "$port" -d .preview
}

## ─── checks ──────────────────────────────────────────────────────────────

# A build the script didn't write is left alone: the run stops before writing anything.
check_build_file() {
    [ -e "$BOOK_DIR/build" ] || return 0
    # One it can't read is another user's: check_book_writable lists it.
    [ -r "$BOOK_DIR/build" ] || return 0
    grep -q 'bootstrap-mdbook.sh' "$BOOK_DIR/build" && return 0
    die "$BOOK_DIR/build exists and wasn't written by this script; move it aside (mv build build.own), then re-run. nothing has been changed."
}

# A newline is the one thing the escaped strings can't carry, so it is refused.
validate_config() {
    # Spelled out, not ranges: under a UTF-8 locale a range lets 'ń' through.
    case "$BOOK_NAME" in
        ""|.*|-*|*[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-]*)
            die "BOOK_NAME is the book's folder: letters, digits, '.', '_' and '-' only, not starting with '.' or '-', got: '$BOOK_NAME'" ;;
    esac
    [ -n "$BOOK_TITLE" ] || die "BOOK_TITLE must not be empty"
    case "$BOOK_TITLE"      in *$'\n'*|*$'\r'*) die "BOOK_TITLE must not contain newlines" ;; esac
    case "$BOOK_AUTHOR"     in *$'\n'*|*$'\r'*) die "BOOK_AUTHOR must not contain newlines" ;; esac
    case "$BOOK_DESCRIPTION" in *$'\n'*|*$'\r'*) die "BOOK_DESCRIPTION must not contain newlines" ;; esac
    case "$DEPLOY_BRANCH"   in *$'\n'*|*$'\r'*) die "DEPLOY_BRANCH must not contain newlines" ;; esac
    case "$HEADING_NUMBERS" in true|false) ;; *) die "HEADING_NUMBERS must be true or false, got: $HEADING_NUMBERS" ;; esac
    case "$SIDEBAR_NUMBERS" in true|false) ;; *) die "SIDEBAR_NUMBERS must be true or false, got: $SIDEBAR_NUMBERS" ;; esac
    case "$THEME"           in dark|auto|picker) ;; *) die "THEME must be dark, auto or picker, got: $THEME" ;; esac
    case "$CODE_LINE_NUMBERS" in true|false) ;; *) die "CODE_LINE_NUMBERS must be true or false, got: $CODE_LINE_NUMBERS" ;; esac
    case "$SIDEBAR_MASTHEAD"  in none|text|image) ;; *) die "SIDEBAR_MASTHEAD must be none, text or image, got: $SIDEBAR_MASTHEAD" ;; esac
    case "$LAYOUT"            in scroll|h2|nested|picker) ;; *) die "LAYOUT must be scroll, h2, nested or picker, got: $LAYOUT" ;; esac
    case "$OFFLINE"           in none|download|full) ;; *) die "OFFLINE must be none, download or full, got: $OFFLINE" ;; esac
    case "$RELEASE_WAIT_DAYS" in ""|*[!0123456789]*) die "RELEASE_WAIT_DAYS must be a whole number of days, got: $RELEASE_WAIT_DAYS" ;; esac
    case "${KEEP_PINS:-false}" in true|false) ;; *) die "KEEP_PINS must be true or false, got: $KEEP_PINS" ;; esac
    [ -z "${MDBOOK_VERSION_OVERRIDE+x}" ] \
        || die "MDBOOK_VERSION_OVERRIDE is gone (KEEP_PINS=true keeps the mdBook the workflow pins): unset it and run this again. Nothing has been changed."
    # Free-form, like the strings above: only newlines are refused.
    case "$FAVICON_TEXT"      in *$'\n'*|*$'\r'*) die "FAVICON_TEXT must not contain newlines" ;; esac

    # Exactly https://github.com/user/repo: a trailing / or .git is dropped, anything else refused.
    case "$GIT_REPO_URL" in
        "") ;;
        *$'\n'*|*$'\r'*) die "GIT_REPO_URL must not contain newlines or carriage returns" ;;
        https://github.com/*)
            GIT_REPO_URL="${GIT_REPO_URL%/}"
            GIT_REPO_URL="${GIT_REPO_URL%.git}"
            repo_path_ok "${GIT_REPO_URL#https://github.com/}" \
                || die "GIT_REPO_URL must be exactly https://github.com/user/repo (nothing after the repo), got: $GIT_REPO_URL" ;;
        *) die "GIT_REPO_URL must be an https github.com URL (https://github.com/user/repo), got: $GIT_REPO_URL" ;;
    esac
}

# True when $1 is exactly user/repo: one slash, something on each side of it.
repo_path_ok() {
    case "$1" in
        */*/*|/*|*/) return 1 ;;
        */*) return 0 ;;
        *) return 1 ;;
    esac
}

## ─── install ─────────────────────────────────────────────────────────────
# curl, python3, git, mdBook, lychee, cargo, mdbook-numbering

# What's missing of curl, python3 and git (with HEADING_NUMBERS, a C compiler), in one stop.
ensure_tools() {
    local miss=() pkgs=() all i
    command -v curl >/dev/null 2>&1 || { miss+=(curl); pkgs+=(curl); }
    command -v python3 >/dev/null 2>&1 || { miss+=(python3); pkgs+=(python3); }
    command -v git >/dev/null 2>&1 || { miss+=(git); pkgs+=(git); }
    if [ "$HEADING_NUMBERS" = true ] && ! cc_ready; then
        miss+=("a C compiler (HEADING_NUMBERS=true builds mdbook-numbering)"); pkgs+=(build-essential)
    fi
    [ "${#miss[@]}" -gt 0 ] || return 0
    all=${miss[0]}
    for ((i = 1; i < ${#miss[@]}; i++)); do
        if [ "$i" -eq $((${#miss[@]} - 1)) ]; then all+=" and ${miss[i]}"; else all+=", ${miss[i]}"; fi
    done
    die "this needs $all: install $([ "${#miss[@]}" -eq 1 ] && printf it || printf them) (on Debian or Ubuntu: sudo apt-get install ${pkgs[*]}), then run this again. Nothing has been changed."
}

# build-essential where dpkg keeps packages (a cc alone may lack the C library's files), else a cc.
cc_ready() {
    if command -v dpkg-query >/dev/null 2>&1; then
        case "$(dpkg-query -W -f='${Status}' build-essential 2>/dev/null || true)" in
            *" installed") return 0 ;;
            *) return 1 ;;
        esac
    fi
    command -v cc >/dev/null 2>&1
}

## ─── releases ────────────────────────────────────────────────────────────
# No config file reaches these: curl runs with -q, git ls-remote without git's config.
github_api() {
    curl -q --fail -sSL --proto =https --proto-redir =https --connect-timeout 30 --max-time 120 -H "Accept: application/vnd.github+json" "https://api.github.com/$1"
}

crates_api() {
    curl -q --fail -sSL --proto =https --proto-redir =https --connect-timeout 30 --max-time 120 -A "bootstrap-mdbook.sh" "https://crates.io/api/v1/$1"
}

fetch() {
    curl -q --fail -sSL --proto =https --proto-redir =https --connect-timeout 30 --max-time 900 "$1" -o "$2"
}

# The commit a tag of github.com/$1 names (an annotated tag's, peeled); nothing for no such tag.
tag_commit() {
    # No repository, no config, no prompt: nothing local can redirect it or stall it.
    env -u GIT_SSL_NO_VERIFY -u GIT_CONFIG_COUNT -u GIT_CONFIG_PARAMETERS -u XDG_CONFIG_HOME -u GIT_ASKPASS -u SSH_ASKPASS \
        HOME=/nonexistent GIT_DIR=/nonexistent GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \
        git -c http.sslVerify=true -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=60 ls-remote "https://github.com/$1" "refs/tags/$2" "refs/tags/$2^{}" |
        awk -v t="refs/tags/$2" '$2 == t "^{}" { peeled = $1 } $2 == t { plain = $1 }
            END { print (peeled != "" ? peeled : plain) }'
}

# The highest-numbered stable release out $4 days, with file $2, in series $3: "tag sha256 [passed]".
pick_release() {
    python3 -I -c '
import datetime as dt, json, re, sys
prefix, asset, series, days = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
cut = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
when = lambda t: dt.datetime.fromisoformat(t.replace("Z", "+00:00"))
data = json.load(sys.stdin.buffer)
best = passed = None
for r in data if isinstance(data, list) else [data]:
    tag = r.get("tag_name") or ""
    m = re.fullmatch(re.escape(prefix) + r"([0-9]+)\.([0-9]+)\.([0-9]+)", tag)
    if not m or r.get("draft") or r.get("prerelease") or not r.get("published_at"):
        continue
    if series and "%s.%s" % m.group(1, 2) != series:
        continue
    if when(r["published_at"]) > cut:
        continue
    key = tuple(int(x) for x in m.groups())
    digest = "-"
    if asset:
        name = asset.replace("{tag}", tag)
        files = [a for a in r.get("assets") or [] if a.get("name") == name and a.get("state") == "uploaded"]
        if not files or max(when(files[0]["created_at"]), when(files[0]["updated_at"])) > cut:
            if passed is None or key > passed[0]:
                passed = (key, tag)
            continue
        d = files[0].get("digest")
        if d is not None and d != "":
            digest = d[7:] if isinstance(d, str) and re.fullmatch("sha256:[0-9a-f]{64}", d) else "?"
    if best is None or key > best[0]:
        best = (key, tag, digest)
if best:
    print(best[1], best[2], *([passed[1]] if passed and passed[0] > best[0] else []))
' "$1" "$2" "$3" "$4"
}

# GitHub's digest now for file $2 of release $1: hex, "-" for none, "?" for an unknown form.
file_digest() {
    python3 -I -c '
import json, re, sys
tag = sys.argv[1]; name = sys.argv[2].replace("{tag}", tag)
data = json.load(sys.stdin.buffer)
for r in data if isinstance(data, list) else [data]:
    if r.get("tag_name") == tag:
        for a in r.get("assets") or []:
            if a.get("name") == name and a.get("state") == "uploaded":
                d = a.get("digest")
                print("-" if d is None or d == "" else d[7:] if isinstance(d, str) and re.fullmatch("sha256:[0-9a-f]{64}", d) else "?")
                sys.exit(0)
' "$1" "$2"
}

# The workflow's pins as "name value [value]" lines; an action pinned alone has the tag "-".
pins() {
    local wf="$BOOK_DIR/.github/workflows/deploy.yml"
    [ -f "$wf" ] || return 0
    sed -n \
        -e 's|.*/rust-lang/mdBook/releases/download/\(v[0-9][0-9.]*\)/mdbook-v[0-9][0-9.]*-x86_64-unknown-linux-gnu\.tar\.gz".*|mdbook \1|p' \
        -e 's|^[[:blank:]]*echo[[:blank:]]\{1,\}"\([0-9a-f]\{64\}\)[[:blank:]]\{1,\}\*\{0,1\}mdbook\.tar\.gz".*|mdbook-sha \1|p' \
        -e 's|.*/lycheeverse/lychee/releases/download/\(lychee-v[0-9][0-9.]*\)/lychee-x86_64-unknown-linux-gnu\.tar\.gz".*|lychee \1|p' \
        -e 's|^[[:blank:]]*echo[[:blank:]]\{1,\}"\([0-9a-f]\{64\}\)[[:blank:]]\{1,\}\*\{0,1\}lychee\.tar\.gz".*|lychee-sha \1|p' \
        -e 's|.*cargo install --locked --version \([0-9][0-9.]*\) mdbook-numbering.*|numbering \1|p' \
        -e 's|^[[:blank:]]*-\{0,1\}[[:blank:]]*uses:[[:blank:]]*\(actions/[a-z-]*\)@\([0-9a-f]\{40\}\)[[:blank:]]\{1,\}#[[:blank:]]*\(v[0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\)[[:space:]]*$|\1 \2 \3|p' \
        -e 's|^[[:blank:]]*-\{0,1\}[[:blank:]]*uses:[[:blank:]]*\(actions/[a-z-]*\)@\([0-9a-f]\{40\}\)[[:blank:]]\{1,\}#.*$|\1 \2 -|p' \
        -e 's|^[[:blank:]]*-\{0,1\}[[:blank:]]*uses:[[:blank:]]*\(actions/[a-z-]*\)@\([0-9a-f]\{40\}\)[[:space:]]*$|\1 \2 -|p' \
        "$wf"
}
pin() {
    pins | awk -v k="$1" '$1 == k { $1 = ""; sub(/^ /, ""); print; exit }'
}

# The pins are the book's only once its repository has a commit (a clone's are the reference repo's).
own_pins() {
    [ -e "$BOOK_DIR/.git" ] && git -C "$BOOK_DIR" rev-parse -q --verify HEAD >/dev/null 2>&1
}

# The book's .git must be a repository git works in as the book's: else stop, before anything is read.
check_repo() {
    [ -e "$BOOK_DIR/.git" ] || return 0
    local top said q gd
    # A .git with files this user can't read (a git command run with sudo leaves root's): git sees none.
    if ! git -C "$BOOK_DIR" --git-dir=.git rev-parse --git-common-dir >/dev/null 2>&1 && ! all_readable "$BOOK_DIR/.git"; then
        said=$(git -C "$BOOK_DIR" --git-dir=.git rev-parse --git-common-dir 2>&1 >/dev/null | grep -m 1 . | sed 's/^fatal: //') || true
        printf -v q '%q' "$(readlink -f "$BOOK_DIR")"
        case "$(readlink -f "$BOOK_DIR/.git")/" in "$(readlink -f "$BOOK_DIR")"/*) ;; *) printf -v q '%s %q' "$q" "$(readlink -f "$BOOK_DIR/.git")" ;; esac
        [ "$EUID" -ne 0 ] || die "git can't read the book's repository ($said), and this run is root's. Nothing has been changed. Run this as the user who owns $BOOK_DIR/.git."
        die "git can't read the book's repository ($said): something in $BOOK_DIR/.git isn't yours to read. Nothing has been changed. Make it yours, then run this again: sudo chown -R \"\$(id -un)\" $q && chmod -R u+rwX $q"
    fi
    if top=$(git -C "$BOOK_DIR" rev-parse --show-toplevel 2>/dev/null); then
        [ "$top" -ef "$BOOK_DIR" ] && return 0
        die "$BOOK_DIR/.git isn't a repository git can read, so git takes the book for part of $top. Nothing has been changed. Move $BOOK_DIR/.git aside, then run this again."
    fi
    said=$(git -C "$BOOK_DIR" rev-parse --show-toplevel 2>&1 >/dev/null | grep -m 1 . | sed 's/^fatal: //') || true
    # Asked by name, git reads a repository whoever owns it: unread, it's none, or another user's.
    if gd=$(git -C "$BOOK_DIR" --git-dir=.git rev-parse --git-common-dir 2>/dev/null); then
        case $gd in /*) ;; *) gd="$BOOK_DIR/$gd" ;; esac
        # Read, and all this user's: something else stops git (a bare .git, say).
        if [ -O "$BOOK_DIR" ] && [ -O "$BOOK_DIR/.git" ] && [ -O "$gd" ]; then
            die "git can't read the book's repository ($said). Nothing has been changed. Fix that, or move $BOOK_DIR/.git aside if it isn't one, then run this again."
        fi
    elif [ -O "$BOOK_DIR/.git" ]; then
        die "git can't read the book's repository ($said). Nothing has been changed. Fix that, or move $BOOK_DIR/.git aside if it isn't one, then run this again."
    else
        gd="$BOOK_DIR/.git"
    fi
    # The folders themselves, not symlinks; the repository's too when it's outside the book.
    printf -v q '%q' "$(readlink -f "$BOOK_DIR")"
    case "$(readlink -f "$gd")/" in "$(readlink -f "$BOOK_DIR")"/*) ;; *) printf -v q '%s %q' "$q" "$(readlink -f "$gd")" ;; esac
    [ "$EUID" -ne 0 ] || die "git won't work in the book's repository ($said): it's another user's, and this run is root's. Nothing has been changed. Give them one owner, then run this again: chown -R root: $q"
    die "git won't work in the book's repository ($said): it's another user's. Nothing has been changed. Make it yours, then run this again: sudo chown -R \"\$(id -un)\" $q"
}

# Every path this run writes, checked first: a stop partway would leave the book half rewritten.
check_book_writable() {
    local bad=() p gd=""
    if [ ! -e "$BOOK_DIR" ] && [ ! -L "$BOOK_DIR" ]; then
        p=$BOOK_DIR
        while [ ! -e "$p" ]; do p=$(dirname "$p"); done
        [ -d "$p" ] && [ -w "$p" ] && [ -x "$p" ] && return 0
        die "can't make $BOOK_DIR: you can't write in $p. Nothing has been changed. Set PROJECT_DIR to a folder of yours, then run this again."
    fi
    [ -d "$BOOK_DIR" ] || die "$BOOK_DIR is there, and isn't a folder. Nothing has been changed. Move it aside, or set BOOK_NAME to another name."
    if [ ! -w "$BOOK_DIR" ] || [ ! -x "$BOOK_DIR" ]; then bad+=("(the book's folder itself)"); fi
    # Written every run: each file, or where it isn't, the folder above it.
    for p in book.toml custom.css custom.js version.js build offline.py .github/workflows/deploy.yml theme theme/fonts; do
        can_write "$p"
    done
    # build is made executable, which only its owner (or root) can do.
    [ ! -e "$BOOK_DIR/build" ] || [ -O "$BOOK_DIR/build" ] || [ "$EUID" -eq 0 ] || bad+=("build (made executable, which only its owner can)")
    # Written on some runs only.
    if [ -f "$BOOK_DIR/src/logo.svg" ] || [ -n "$FAVICON_TEXT" ]; then can_write theme/favicon.svg; fi
    if [ ! -f "$BOOK_DIR/src/SUMMARY.md" ]; then
        for p in src/SUMMARY.md src/about.md src/chapter_1.md src/demo.svg; do can_write "$p"; done
    fi
    [ -e "$BOOK_DIR/README.md" ] || can_write README.md
    [ -d "$BOOK_DIR/src/fonts" ] || can_write src/fonts
    if ! { [ -f "$BOOK_DIR/.gitignore" ] && grep -qxF 'bin/' "$BOOK_DIR/.gitignore" && grep -qxF 'book/' "$BOOK_DIR/.gitignore" \
        && grep -qxF '.preview/' "$BOOK_DIR/.gitignore"; }; then
        can_write .gitignore
    fi
    # book/ and .preview/, which mdBook empties and refills: every folder in them.
    for p in book .preview; do
        [ -d "$BOOK_DIR/$p" ] || continue
        p=$(find -H "$BOOK_DIR/$p" -type d ! -writable -print -quit 2>/dev/null || true)
        [ -z "$p" ] || bad+=("${p#"$BOOK_DIR"/}/ (the first such folder there; mdBook empties and fills them all)")
    done
    # The repository: origin is set in its config, and the first commit written in it.
    if [ -e "$BOOK_DIR/.git" ]; then
        gd=$(cd "$BOOK_DIR" && git rev-parse --git-common-dir 2>/dev/null) || gd=""
        case $gd in "") ;; /*) ;; *) gd="$BOOK_DIR/$gd" ;; esac
        if [ -n "$gd" ]; then
            # Not when origin is that address already: the config is left as it is.
            [ -z "$GIT_REPO_URL" ] || [ -w "$gd" ] \
                || [ "$(git -C "$BOOK_DIR" config --get remote.origin.url 2>/dev/null || true)" = "$(origin_ssh)" ] \
                || bad+=("${gd#"$BOOK_DIR"/}/ (origin is set in its config)")
            if ! git -C "$BOOK_DIR" rev-parse -q --verify HEAD >/dev/null 2>&1; then
                p=$(find -H "$gd" -type d ! -writable -print -quit 2>/dev/null || true)
                [ -z "$p" ] || bad+=("${p#"$BOOK_DIR"/}/ (the first commit is written in it)")
            fi
        fi
    fi
    [ "${#bad[@]}" -eq 0 ] || unwritable "$gd"
}

# Whether this user can read every file and folder in $1 (symlinks aside).
all_readable() {
    [ -z "$(find -H "$1" ! -type l \( ! -readable -o -type d ! -executable \) -print -quit 2>&1)" ]
}

# The write checks' stop: what's in bad, and what to do.
unwritable() {
    local gd=$1 p q ro="" ia=""
    {
        echo "ERROR: this run writes these in $BOOK_DIR, and can't; nothing has been changed:"
        printf '%s\n' "${bad[@]}" | awk '!seen[$0]++ { print "    " $0 }'
        # A read-only mount, or an immutable or append-only file, defeats a chown: say which.
        for p in "${bad[@]}"; do
            p=${p%% (*}; p=${p%/}
            case $p in "("*) p=$BOOK_DIR ;; /*) ;; *) p="$BOOK_DIR/$p" ;; esac
            findmnt -no OPTIONS -T "$p" 2>/dev/null | tr , '\n' | grep -qx ro && { ro=$p; break; }
            [ -n "$ia" ] || ! lsattr -d -- "$p" 2>/dev/null | awk '$1 ~ /[ia]/ { f = 1 } END { exit !f }' || ia=$p
        done
        if [ -n "$ro" ]; then
            echo "$ro is on a filesystem mounted read-only (findmnt -T \"$ro\"): put the book where you can write, or mount it read-write."
        elif [ -n "$ia" ]; then
            if [ "$EUID" -eq 0 ]; then p=""; else p="sudo "; fi
            printf -v q '%q' "$ia"
            echo "$ia is immutable or append-only (lsattr -d), which stops root too; take that off, and the same for any other listed: ${p}chattr -i -a $q"
        elif [ "$EUID" -eq 0 ]; then
            echo "This run is root's, and root can't write them either: a read-only mount (findmnt -T \"$BOOK_DIR\"), an immutable file (lsattr), or a network folder or container that treats root as another user."
        else
            # The folders themselves, not symlinks: chown -R changes a symlink it's given.
            printf -v q '%q' "$(readlink -f "$BOOK_DIR")"
            if [ -n "$gd" ]; then
                case "$(readlink -f "$gd")/" in "$(readlink -f "$BOOK_DIR")"/*) ;; *) printf -v q '%s %q' "$q" "$(readlink -f "$gd")" ;; esac
            fi
            echo "Make them yours and writable, then run this again: sudo chown -R \"\$(id -un)\" $q && chmod -R u+rwX $q"
        fi
    } >&2
    exit 1
}

# bin/, once the releases are known: only when a release goes in, or with HEADING_NUMBERS.
check_bin_writable() {
    local bad=() m="" l=""
    [ -d "$BOOK_DIR" ] || return 0
    (cd "$BOOK_DIR" && mdbook_installed) || m=1
    (cd "$BOOK_DIR" && lychee_installed) || l=1
    if [ -z "$m$l" ] && [ "$HEADING_NUMBERS" != true ]; then return 0; fi
    can_write bin
    if [ -n "$m" ]; then can_write bin/.mdbook.sha256; can_write bin/.mdbook.version; fi
    if [ -n "$l" ]; then can_write bin/.lychee.sha256; can_write bin/.lychee.version; fi
    [ "${#bad[@]}" -eq 0 ] || unwritable ""
}

# A run as root on a book that isn't root's would leave root's files in it: stop.
check_sudo() {
    [ "$EUID" -eq 0 ] || return 0
    local p=$BOOK_DIR
    while [ ! -e "$p" ]; do p=$(dirname "$p"); done
    if [ "$(stat -L -c %u "$p")" != 0 ]; then
        [ -z "${SUDO_UID:-}" ] || [ "$SUDO_UID" = 0 ] \
            || die "this is a run with sudo, and $p isn't root's: it would leave root's files in the book. Nothing has been changed. Run this without sudo."
        die "this run is root's, and $p isn't: it would leave root's files in the book. Nothing has been changed. Run this as the user who owns $p."
    fi
    # With HEADING_NUMBERS, rustup and cargo write in CARGO_HOME, RUSTUP_HOME and HOME too.
    [ "$HEADING_NUMBERS" = true ] || return 0
    for p in "${CARGO_HOME:-$HOME/.cargo}" "${RUSTUP_HOME:-$HOME/.rustup}" "$HOME"; do
        while [ ! -e "$p" ]; do p=$(dirname "$p"); done
        [ "$(stat -L -c %u "$p")" = 0 ] \
            || die "this run is root's, and $p isn't: with HEADING_NUMBERS, rustup and cargo would leave root's files there. Nothing has been changed. Run with root's own HOME (sudo -H), and CARGO_HOME and RUSTUP_HOME unset."
    done
}

# For check_book_writable: $1, or the nearest folder above it, must take the write; else onto bad.
can_write() {
    local f="$BOOK_DIR/$1" up slash=""
    if [ -e "$f" ] || [ -L "$f" ]; then
        [ ! -d "$f" ] || slash=/
        # Append-only (chattr +a) passes -w but can't be rewritten.
        { [ -w "$f" ] && ! lsattr -d -- "$f" 2>/dev/null | awk '$1 ~ /a/ { a = 1 } END { exit !a }'; } || bad+=("$1$slash")
        return 0
    fi
    up=${f%/*}
    while [ ! -e "$up" ] && [ ! -L "$up" ]; do up=${up%/*}; done
    # A symlink to nothing (a drive that isn't mounted): no folder is made there.
    [ -e "$up" ] || { bad+=("${up#"$BOOK_DIR"/} (a symlink to $(readlink -- "$up"), which isn't there)"); return 0; }
    [ -w "$up" ] && [ -x "$up" ] && return 0
    if [ "$up" = "$BOOK_DIR" ]; then bad+=("(the book's folder itself)"); else bad+=("${up#"$BOOK_DIR"/}/"); fi
}

# A pin ("commit tag") as a uses: value, and a uses: value as a run names it.
pin_uses() {
    if [ "${2#* }" = - ]; then printf '%s@%s' "$1" "${2%% *}"; else printf '%s@%s # %s' "$1" "${2%% *}" "${2#* }"; fi
}
uses_name() {
    local c=${1##*@}
    case "$1" in *" # "*) printf '%s' "${1##*# }" ;; *) printf 'commit %s' "${c:0:12}" ;; esac
}

LIMIT_HINT="no network, a proxy refusing api.github.com, or GitHub's limit of 60 requests an hour from one address: try again within the hour"
CRATES_HINT="no network, a proxy refusing crates.io, or crates.io not answering"
DL_HINT="no network, a proxy refusing github.com or release-assets.githubusercontent.com, or, with a 404, the release pulled: a run without KEEP_PINS takes another"

# A release for $1 from github.com/$2; GitHub out of reach, the pin. Sets REL_TAG and REL_DIGEST.
lookup_tool() {
    local name=$1 repo=$2 prefix=$3 asset=$4 series=$5 json got ptag psha now label=$1 shown passed stays=""
    [ "$name" != mdbook ] || label=mdBook
    shown=${asset//\{tag\}/vX.Y.Z}
    REL_TAG="" REL_DIGEST=""
    ptag=$(pin "$name") psha=$(pin "$name-sha")
    own_pins || ptag="" psha=""
    if json=$(github_api "repos/$repo/releases?per_page=100"); then
        got=$(printf '%s' "$json" | pick_release "$prefix" "$asset" "$series" "$RELEASE_WAIT_DAYS") || die "could not read $repo's releases; nothing has been changed"
        if [ -n "$got" ] && [ -n "$series" ]; then
            local newest
            # Only to say so: a release it can't read in another series says nothing.
            newest=$(printf '%s' "$json" | pick_release "$prefix" "$asset" "" "$RELEASE_WAIT_DAYS" 2>/dev/null) || newest=""
            [ -z "$newest" ] || [ "${newest%% *}" = "${got%% *}" ] || stays="$label ${newest%% *} is out, but this script's theme and scripts are written for $label $series: using ${got%% *}"
        fi
    else
        # A version pinned without its digest can't be checked: as on a first run.
        { [ -n "$ptag" ] && [ -n "$psha" ]; } || die "could not look up $repo's releases ($LIMIT_HINT); nothing has been changed. Run this again later."
        warn "could not look up $repo's releases ($LIMIT_HINT): keeping $label ${ptag#lychee-}, which the workflow pins"
        REL_TAG=$ptag REL_DIGEST=$psha
        return 0
    fi
    # The pinned release first: a replaced file stops the run even when no release qualifies.
    if [ -n "$ptag" ] && [ -n "$psha" ]; then
        now=$(printf '%s' "$json" | file_digest "$ptag" "$asset") || die "could not read $repo's releases; nothing has been changed"
        if [ -n "$now" ] && [ "$now" != - ] && [ "$now" != "?" ] && [ "$now" != "$psha" ]; then
            die "GitHub's digest for $label ${ptag#lychee-} is $now, but the workflow pins $psha for it: the file has changed since. Nothing has been changed. Check the release's page on GitHub; to go on, delete the line with $psha from .github/workflows/deploy.yml and run this again."
        fi
    fi
    [ -n "$got" ] || die "no stable $repo release${series:+ in the $series series} with $shown has been out $RELEASE_WAIT_DAYS days yet; nothing has been changed"
    read -r REL_TAG REL_DIGEST passed <<< "$got"
    # A digest in an unknown form: for the pinned release, the pinned one; else stop.
    if [ "$REL_DIGEST" = "?" ]; then
        if [ "$REL_TAG" != "$ptag" ] || [ -z "$psha" ]; then
            # On a re-run, what KEEP_PINS keeps is a way on.
            local keep=""
            [ -z "$ptag" ] || [ -z "$psha" ] || [[ $ptag != "$prefix${series:+$series.}"* ]] \
                || keep=" Till then, KEEP_PINS=true keeps $label ${ptag#lychee-}, which the workflow pins."
            die "GitHub gives the digest of $label ${REL_TAG#lychee-}'s ${asset//\{tag\}/$REL_TAG} in a form this script doesn't know, so the download can't be checked. Nothing has been changed.$keep"
        fi
        REL_DIGEST=$psha
    fi
    [ -z "$stays" ] || say "$stays"
    # A newer release passed over for its file: said, or a renamed file would hold every run back.
    if [ -n "$passed" ] && { [ "$passed" != "$ptag" ] || [ -z "$psha" ]; }; then
        warn "$label ${passed#lychee-} is out, but has no ${asset//\{tag\}/$passed} uploaded $RELEASE_WAIT_DAYS days ago or more: using ${REL_TAG#lychee-}"
    fi
    [ -n "$ptag" ] && [ -n "$psha" ] || return 0
    # A file from before GitHub made digests has none: the pinned one, then.
    [ "$REL_TAG" != "$ptag" ] || [ "$REL_DIGEST" != - ] || REL_DIGEST=$psha
    # The pin passed over for an older release: a step back, said.
    if [ "$REL_TAG" != "$ptag" ] && { [ -z "$series" ] || [[ $ptag == "$prefix$series".* ]]; } \
        && [ "$(printf '%s\n' "$REL_TAG" "$ptag" | sort -V | tail -n 1)" = "$ptag" ]; then
        warn "the workflow pins $label ${ptag#lychee-}, which GitHub doesn't offer now as a stable release out $RELEASE_WAIT_DAYS days: using ${REL_TAG#lychee-}. Its releases page may say why."
    fi
}

# The refusal for an action whose pinned tag ($2, "commit tag") names $3 now.
tag_moved() {
    die "$1's tag ${2#* } now names commit $3, but the workflow pins ${2%% *} for it: the tag has been moved. Nothing has been changed. Check the release's page on GitHub; to go on, delete its 'uses:' line from .github/workflows/deploy.yml and run this again."
}

# An action at its newest release out RELEASE_WAIT_DAYS days, pinned by commit. Sets USES.
lookup_action() {
    local repo=$1 json got tag sha old was
    old=$(pin "$repo")
    own_pins || old=""
    if json=$(github_api "repos/$repo/releases?per_page=100"); then
        got=$(printf '%s' "$json" | pick_release v "" "" "$RELEASE_WAIT_DAYS") || die "could not read $repo's releases; nothing has been changed"
        if [ -z "$got" ]; then
            # A moved pinned tag stops the run even when no release qualifies.
            if [ -n "$old" ] && [ "${old#* }" != - ] && was=$(tag_commit "$repo" "${old#* }" 2>/dev/null) \
                && [ -n "$was" ] && [ "${old%% *}" != "$was" ]; then
                tag_moved "$repo" "$old" "$was"
            fi
            die "no stable $repo release has been out $RELEASE_WAIT_DAYS days yet; nothing has been changed"
        fi
        tag=${got%% *}
        # git failing is GitHub out of reach, as when the API can't be asked.
        if ! sha=$(tag_commit "$repo" "$tag"); then
            [ -n "$old" ] || die "could not ask github.com which commit $repo's tag $tag names (git ls-remote failed, above). Nothing has been changed. Run this again later."
            USES=$(pin_uses "$repo" "$old")
            warn "could not ask github.com which commit $repo's tag $tag names (git ls-remote failed, above): keeping $repo $(uses_name "$USES"), which the workflow pins"
            return 0
        fi
        case "$sha" in
            *[!0123456789abcdef]*|"") die "could not find the commit $repo's tag $tag names (git ls-remote https://github.com/$repo); nothing has been changed" ;;
        esac
        [ "${#sha}" -eq 40 ] || die "could not find the commit $repo's tag $tag names (git ls-remote https://github.com/$repo); nothing has been changed"
        # A pinned tag that's gone proves nothing; a commit pinned alone has no tag.
        if [ -n "$old" ] && [ "${old#* }" != - ]; then
            was=$sha
            if [ "${old#* }" != "$tag" ] && ! was=$(tag_commit "$repo" "${old#* }"); then
                USES=$(pin_uses "$repo" "$old")
                warn "could not ask github.com which commit $repo's tag ${old#* } names (git ls-remote failed, above): keeping $repo $(uses_name "$USES"), which the workflow pins"
                return 0
            fi
            if [ -n "$was" ] && [ "${old%% *}" != "$was" ]; then
                tag_moved "$repo" "$old" "$was"
            fi
            if [ "${old#* }" != "$tag" ] && [ "$(printf '%s\n' "$tag" "${old#* }" | sort -V | tail -n 1)" = "${old#* }" ]; then
                warn "the workflow pins $repo ${old#* }, which GitHub doesn't offer now as a stable release out $RELEASE_WAIT_DAYS days: using $tag. Its releases page may say why."
            fi
        fi
    elif [ -n "$old" ]; then
        USES=$(pin_uses "$repo" "$old")
        warn "could not look up $repo's releases ($LIMIT_HINT): keeping $repo $(uses_name "$USES"), which the workflow pins"
        return 0
    else
        die "could not look up $repo's releases ($LIMIT_HINT); nothing has been changed. Run this again later."
    fi
    USES="$repo@$sha # $tag"
}

# mdbook-numbering: the newest unyanked version out RELEASE_WAIT_DAYS days. Sets NUMBERING_VERSION.
lookup_numbering() {
    local json old
    if json=$(crates_api "crates/mdbook-numbering/versions"); then
        NUMBERING_VERSION=$(printf '%s' "$json" | python3 -I -c '
import datetime as dt, json, re, sys
cut = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=int(sys.argv[1]))
best = None
for v in json.load(sys.stdin.buffer).get("versions") or []:
    m = re.fullmatch(r"([0-9]+)\.([0-9]+)\.([0-9]+)", v.get("num") or "")
    if not m or v.get("yanked") or not v.get("created_at"):
        continue
    if dt.datetime.fromisoformat(v["created_at"].replace("Z", "+00:00")) > cut:
        continue
    key = tuple(int(x) for x in m.groups())
    if best is None or key > best[0]:
        best = (key, v["num"])
if best:
    print(best[1])
' "$RELEASE_WAIT_DAYS") || die "could not read crates.io's versions of mdbook-numbering; nothing has been changed"
        [ -n "$NUMBERING_VERSION" ] || die "no version of mdbook-numbering that hasn't been yanked has been out $RELEASE_WAIT_DAYS days yet; nothing has been changed"
        old=$(pin numbering)
        own_pins || old=""
        if [ -n "$old" ] && [ "$old" != "$NUMBERING_VERSION" ] \
            && [ "$(printf '%s\n' "$NUMBERING_VERSION" "$old" | sort -V | tail -n 1)" = "$old" ]; then
            warn "the workflow pins mdbook-numbering $old, which crates.io doesn't offer now, unyanked and out $RELEASE_WAIT_DAYS days: using $NUMBERING_VERSION"
        fi
    else
        old=$(pin numbering)
        own_pins || old=""
        [ -n "$old" ] || die "could not look up mdbook-numbering on crates.io ($CRATES_HINT); nothing has been changed. Run this again later."
        warn "could not look up mdbook-numbering on crates.io ($CRATES_HINT): keeping mdbook-numbering $old, which the workflow pins"
        NUMBERING_VERSION=$old
    fi
}

# Every release this run uses; with KEEP_PINS, those the workflow pins. mdBook stays on 0.5.
MDBOOK_SERIES="0.5"
lookup_releases() {
    local keep="" v where=GitHub
    # KEEP_PINS: checked in validate_config.
    [ "${KEEP_PINS:-false}" != true ] || keep=1
    KEPT=""
    # Said first, so a slow network doesn't look like a hang.
    [ "$HEADING_NUMBERS" != true ] || where="GitHub and crates.io"
    [ -n "$keep" ] || say "looking up the newest releases on $where"
    if [ -n "$keep" ] && [ -n "$(pin mdbook)" ] && [ -n "$(pin mdbook-sha)" ]; then
        MDBOOK_VERSION=$(pin mdbook) MDBOOK_DIGEST=$(pin mdbook-sha) KEPT=1
    else
        unpinned "$keep" mdBook
        lookup_tool mdbook rust-lang/mdBook v "mdbook-{tag}-x86_64-unknown-linux-gnu.tar.gz" "$MDBOOK_SERIES"
        MDBOOK_VERSION=$REL_TAG MDBOOK_DIGEST=$REL_DIGEST
    fi
    # Kept pins too: the theme is written for 0.5's pages.
    if [[ $MDBOOK_VERSION != "v$MDBOOK_SERIES".* ]]; then
        if [ -n "$keep" ]; then
            die "the workflow pins mdBook $MDBOOK_VERSION, and this script is written for $MDBOOK_SERIES. Nothing has been changed. Run without KEEP_PINS."
        fi
        die "with GitHub out of reach this run would keep mdBook $MDBOOK_VERSION, and this script is written for $MDBOOK_SERIES. Nothing has been changed. Run this again later."
    fi
    if [ -n "$keep" ] && [ -n "$(pin lychee)" ] && [ -n "$(pin lychee-sha)" ]; then
        v=$(pin lychee); LYCHEE_VERSION=${v#lychee-v} LYCHEE_DIGEST=$(pin lychee-sha) KEPT=1
    else
        unpinned "$keep" lychee
        lookup_tool lychee lycheeverse/lychee lychee-v "lychee-x86_64-unknown-linux-gnu.tar.gz" ""
        LYCHEE_VERSION=${REL_TAG#lychee-v} LYCHEE_DIGEST=$REL_DIGEST
    fi
    keep_or_lookup_action actions/checkout "$keep"; CHECKOUT_USES=$USES
    keep_or_lookup_action actions/upload-pages-artifact "$keep"; UPLOAD_USES=$USES
    keep_or_lookup_action actions/deploy-pages "$keep"; DEPLOY_USES=$USES
    if [ "$HEADING_NUMBERS" = true ]; then
        if [ -n "$keep" ] && [ -n "$(pin numbering)" ]; then
            NUMBERING_VERSION=$(pin numbering) KEPT=1
        else
            unpinned "$keep" mdbook-numbering
            lookup_numbering
        fi
    fi
    say "releases: mdBook $MDBOOK_VERSION, lychee $LYCHEE_VERSION${NUMBERING_VERSION:+, mdbook-numbering $NUMBERING_VERSION}; $(uses_name "$CHECKOUT_USES"), $(uses_name "$UPLOAD_USES"), $(uses_name "$DEPLOY_USES") of the actions${KEPT:+ (KEEP_PINS: those the workflow pins, kept)}"
}

# KEEP_PINS with nothing pinned for it: looked up, and said.
unpinned() {
    [ -z "$1" ] || say "KEEP_PINS: nothing to keep for $2 in the workflow; looking it up"
}

keep_or_lookup_action() {
    local v
    v=$(pin "$1")
    if [ -n "$2" ] && [ -n "$v" ]; then
        USES=$(pin_uses "$1" "$v") KEPT=1
    else
        unpinned "$2" "$1"
        lookup_action "$1"
    fi
}

# Downloads $1 to $2, checked against digest $3 ("-": none, so its own is pinned). Prints the digest.
fetch_checked() {
    local got
    fetch "$1" "$2" || die "could not download $1 ($DL_HINT); it wasn't installed"
    [ -s "$2" ] || die "the download of $1 is empty"
    got=$(sha256sum "$2" | awk '{print $1}')
    case "$got" in
        [0-9a-f][0-9a-f]*) ;;
        *) die "could not compute the sha256 of $2" ;;
    esac
    if [ "$3" = - ]; then
        warn "GitHub has no digest for $1: pinning its own, $got"
    elif [ "$got" != "$3" ]; then
        die "$1 isn't the file it should be: its sha256 is $got, not $3. It wasn't installed."
    fi
    printf '%s' "$got"
}

current_mdbook_version() {
    [ -x "bin/mdbook" ] || return 0
    # One that won't run (another platform's, a broken download) is none.
    bin/mdbook --version 2>/dev/null | awk 'NR == 1 { print $2 }' || true
}

# Stops when binary $1 ($2, bin/$3, pinned $4) can't run here: too old a glibc, or a noexec mount.
runs_here() {
    local out rc need have keep="" tool=${3:-} pinned=${4:-} probe="${1%/*}/.probe"
    out=$("$1" --version 2>&1) && return 0
    rc=$?
    # Said by what it printed, or, printing nothing, by how it ended.
    [ -n "$out" ] || out="its --version printed nothing and exited $rc"
    # Not even a script runs from bin/: the filesystem is mounted noexec.
    if printf '#!/bin/sh\n' 2>/dev/null > "$probe" && chmod +x "$probe" && ! "$probe" 2>/dev/null; then
        die "$2 can't run from the book's bin/: the filesystem $BOOK_DIR is on doesn't allow running programs (it's mounted noexec). It wasn't installed. Put the book in your home folder (PROJECT_DIR), then run this again."
    fi
    # KEEP_PINS is offered only when bin/ runs the release the workflow pins, and it's another.
    if [ "${KEEP_PINS:-false}" != true ] && [ -n "$pinned" ] && [ "$pinned" != "${2#* }" ] \
        && [ "$("bin/$tool" --version 2>/dev/null | awk 'NR == 1 { print $2 }')" = "$pinned" ]; then
        keep=" KEEP_PINS=true keeps ${2%% *} $pinned, which the workflow pins and bin/ has."
    fi
    need=$(printf '%s\n' "$out" | grep -o 'GLIBC_[0-9][0-9.]*' | sort -V | tail -n 1 || true)
    have=$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}' || true)
    [ -z "$need" ] || die "$2's Linux build needs glibc ${need#GLIBC_} or later, and this system has ${have:-an older one}: it wasn't installed, and bin/ keeps the ${2%% *} it had, if any. Run this on a system with glibc ${need#GLIBC_} or later.${keep}"
    die "$2 doesn't run here ($(printf '%s' "$out" | head -n 2 | tr '\n' ' ')): it wasn't installed, and bin/ keeps the ${2%% *} it had, if any.${keep}"
}

# bin/ has this run's release, by digest and version, and it runs: nothing to install.
mdbook_installed() {
    local current
    current=$(current_mdbook_version)
    [ -n "$current" ] && [ "${current#v}" = "${MDBOOK_VERSION#v}" ] && [ -s bin/.mdbook.sha256 ] \
        && [ "$(cat bin/.mdbook.version 2>/dev/null)" = "$MDBOOK_VERSION" ] \
        && [ "$MDBOOK_DIGEST" != - ] && [ "$(cat bin/.mdbook.sha256 2>/dev/null)" = "$MDBOOK_DIGEST" ]
}
lychee_installed() {
    [ -x bin/lychee ] && [ "$(bin/lychee --version 2>/dev/null | head -n 1)" = "lychee ${LYCHEE_VERSION}" ] \
        && [ -s bin/.lychee.sha256 ] && [ "$(cat bin/.lychee.version 2>/dev/null)" = "$LYCHEE_VERSION" ] \
        && [ "$LYCHEE_DIGEST" != - ] && [ "$(cat bin/.lychee.sha256 2>/dev/null)" = "$LYCHEE_DIGEST" ]
}
install_mdbook() {
    local version triple archive url bin
    version="$MDBOOK_VERSION"

    if mdbook_installed; then
        MDBOOK_SHA256=$(cat bin/.mdbook.sha256)
        say "mdBook $(current_mdbook_version) already installed"
        return
    fi

    triple="x86_64-unknown-linux-gnu"
    archive="mdbook-${version}-${triple}.tar.gz"
    url="https://github.com/rust-lang/mdBook/releases/download/${version}/${archive}"

    make_tmp
    say "downloading mdBook ${version}..."
    MDBOOK_SHA256=$(fetch_checked "$url" "$TMP_DIR/$archive" "$MDBOOK_DIGEST")

    # The pin KEEP_PINS would keep, offered when it isn't this release.
    local pinned keep=""
    pinned=$(pin mdbook)
    [ -n "$(pin mdbook-sha)" ] && [[ $pinned == "v$MDBOOK_SERIES".* ]] || pinned=""
    own_pins || pinned=""
    [ "${KEEP_PINS:-false}" = true ] || [ -z "$pinned" ] || [ "$pinned" = "$version" ] \
        || keep=" Till then, KEEP_PINS=true keeps mdBook $pinned, which the workflow pins."
    # --no-same-owner: as root, tar would keep the archive's uid, who could then swap what root runs.
    tar --no-same-owner -xzf "$TMP_DIR/$archive" -C "$TMP_DIR" mdbook \
        || die "no mdbook at the top of $archive: this release lays its archive out in a way this script doesn't know.$keep"
    bin="$TMP_DIR/mdbook"
    chmod +x "$bin"
    runs_here "$bin" "mdBook $version" mdbook "$pinned"
    # -f: over a read-only bin/mdbook, mv would ask first.
    mv -f "$bin" "$BOOK_DIR/bin/mdbook"
    printf '%s' "$MDBOOK_SHA256" > bin/.mdbook.sha256
    printf '%s' "$version" > bin/.mdbook.version
    say "installed: $(./bin/mdbook --version) (sha256 ${MDBOOK_SHA256:0:12}...)"
}

# lychee into bin/ beside mdBook: the bytes CI runs, checked and recorded as mdBook's are.
install_lychee() {
    local archive="lychee-x86_64-unknown-linux-gnu.tar.gz"
    if lychee_installed; then
        LYCHEE_SHA256=$(cat bin/.lychee.sha256)
        say "lychee ${LYCHEE_VERSION} already installed"
        return
    fi
    make_tmp
    say "downloading lychee ${LYCHEE_VERSION}..."
    LYCHEE_SHA256=$(fetch_checked "https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/${archive}" \
        "$TMP_DIR/$archive" "$LYCHEE_DIGEST")
    local pinned keep=""
    pinned=$(pin lychee)
    [ -n "$(pin lychee-sha)" ] || pinned=""
    own_pins || pinned=""
    [ "${KEEP_PINS:-false}" = true ] || [ -z "$pinned" ] || [ "${pinned#lychee-v}" = "$LYCHEE_VERSION" ] \
        || keep=" Till then, KEEP_PINS=true keeps lychee ${pinned#lychee-v}, which the workflow pins."
    tar --no-same-owner -xzf "$TMP_DIR/$archive" -C "$TMP_DIR" "lychee-x86_64-unknown-linux-gnu/lychee" \
        || die "could not take lychee out of $archive: this release lays its archive out in a way this script doesn't know.$keep"
    runs_here "$TMP_DIR/lychee-x86_64-unknown-linux-gnu/lychee" "lychee $LYCHEE_VERSION" lychee "${pinned#lychee-v}"
    mv -f "$TMP_DIR/lychee-x86_64-unknown-linux-gnu/lychee" bin/lychee
    printf '%s' "$LYCHEE_SHA256" > bin/.lychee.sha256
    printf '%s' "$LYCHEE_VERSION" > bin/.lychee.version
    say "installed: $(./bin/lychee --version)"
}

# HEADING_NUMBERS: how this terminal finds mdbook-numbering when its PATH lacks cargo's bin/.
CARGO_NOTE=""
cargo_note() {
    case "$CARGO_NOTE" in
        env)  say "this terminal's PATH lacks ${CARGO_HOME:-~/.cargo}/bin, where mdbook-numbering is: run . \"${CARGO_HOME:-\$HOME/.cargo}/env\" here before ./build or ./bin/mdbook serve." ;;
        path) say "this terminal's PATH lacks ${CARGO_HOME:-$HOME/.cargo}/bin, where cargo put mdbook-numbering: add it, or ./build stops without it." ;;
    esac
}
# rustup's folder is CARGO_HOME when that's set, as cargo's is.
ensure_cargo() {
    local home="${CARGO_HOME:-$HOME/.cargo}"
    command -v cargo >/dev/null 2>&1 && return
    CARGO_NOTE="env"
    if [ -f "$home/env" ]; then
        # shellcheck source=/dev/null
        source "$home/env"
        command -v cargo >/dev/null 2>&1 && return
    fi

    # The C compiler cargo links with is checked in ensure_tools.
    make_tmp
    say "installing rustup..."
    fetch https://sh.rustup.rs "$TMP_DIR/rustup-init.sh" || die "could not download rustup's installer from https://sh.rustup.rs (behind a proxy, set https_proxy)"
    # An empty .curlrc in CURL_HOME keeps ~/.curlrc from rustup's own curl.
    : > "$TMP_DIR/.curlrc"
    CURL_HOME="$TMP_DIR" TMPDIR="$TMP_DIR" sh "$TMP_DIR/rustup-init.sh" -y --default-toolchain stable >/dev/null

    [ -f "$home/env" ] || die "cargo env file missing after rustup install"
    # shellcheck source=/dev/null
    source "$home/env"
    export PATH="$home/bin:$PATH"
    command -v cargo >/dev/null 2>&1 || die "cargo not on PATH after install"
}

# The version lookup_numbering found, pinned in the workflow like mdBook.
ensure_mdbook_numbering() {
    local root="${CARGO_HOME:-$HOME/.cargo}/bin" hint="" pv
    # grep without -q: quitting at the match would fail cargo on a broken pipe (pipefail).
    if ! cargo install --list 2>/dev/null | grep "^mdbook-numbering v${NUMBERING_VERSION}:" >/dev/null; then
        say "installing mdbook-numbering ${NUMBERING_VERSION}..."
        # On a re-run, the release the workflow pins, when cargo has it.
        pv=$(pin numbering)
        own_pins || pv=""
        if [ "${KEEP_PINS:-false}" != true ] && [ -n "$pv" ] && [ "$pv" != "$NUMBERING_VERSION" ] \
            && cargo install --list 2>/dev/null | grep "^mdbook-numbering v${pv}:" >/dev/null; then
            hint=" KEEP_PINS=true keeps mdbook-numbering $pv, which the workflow pins and cargo has installed."
        fi
        # A distribution's Rust is often older than a crate needs.
        [ "$(command -v cargo)" = "$root/cargo" ] \
            || hint=" If it asks for a newer Rust: this cargo is $(command -v cargo) ($(cargo --version 2>/dev/null || true)), and rustup (https://rustup.rs) installs the current one.$hint"
        # cargo runs the build scripts it compiles from TMPDIR: in bin/, as the downloads are.
        make_tmp
        TMPDIR="$TMP_DIR" cargo install --locked --version "$NUMBERING_VERSION" mdbook-numbering \
            || die "cargo couldn't install mdbook-numbering ${NUMBERING_VERSION}; it says why, above.${hint} Run this again once that's fixed."
    fi
    # mdBook runs it by name, and a distribution's cargo installs where PATH doesn't look.
    if ! command -v mdbook-numbering >/dev/null 2>&1; then
        export PATH="$root:$PATH"
        [ -n "$CARGO_NOTE" ] || CARGO_NOTE="path"
    fi
}

# mdbook-numbering's code numbering breaks the copy button: off; custom.js draws a gutter instead.
ensure_numbering_config() {
    grep -q '^\[preprocessor\.numbering\]' book.toml && return
    cat >> book.toml <<'EOF'

[preprocessor.numbering]

[preprocessor.numbering.heading]
enable = true
numbering-style = "consecutive"

[preprocessor.numbering.code]
enable = false
EOF
    say "appended numbering config to book.toml"
}

init_git_repo() {
    [ -e ".git" ] && return
    if git init -b "$DEPLOY_BRANCH" >/dev/null 2>&1; then
        say "initialised git repo on branch: $DEPLOY_BRANCH"
    else
        warn "git init failed - run manually later"
    fi
}

# origin from GIT_REPO_URL, in its ssh form (git@github.com:user/repo.git); never pushed.
origin_ssh() {
    local rest="${GIT_REPO_URL#https://}"
    printf 'git@%s:%s.git' "${rest%%/*}" "${rest#*/}"
}
ensure_git_remote() {
    [ -e ".git" ] || return 0
    [ -n "$GIT_REPO_URL" ] || return 0
    local ssh
    ssh=$(origin_ssh)
    if git remote get-url origin >/dev/null 2>&1; then
        # As configured: get-url applies url.*.insteadOf rewrites.
        [ "$(git config --get remote.origin.url)" != "$ssh" ] || return 0
        if git remote set-url origin "$ssh"; then say "updated origin -> $ssh"; else warn "could not update origin"; fi
    else
        if git remote add origin "$ssh"; then say "set origin -> $ssh"; else warn "could not add origin"; fi
    fi
}

# The first commit, only with an identity the author set, and in UTC; who it's from is said.
git_initial_commit() {
    [ -e ".git" ] || return 0
    git rev-parse HEAD >/dev/null 2>&1 && return 0
    git add -A
    local err
    if err=$(TZ=UTC git -c user.useConfigOnly=true commit -q -m "initial mdBook scaffold" 2>&1); then
        # As a push publishes it: the author, the committer if another, and each time's offset.
        local as by off when also=""
        as=$(git -c log.showSignature=false log -1 --format='%an <%ae>')
        by=$(git -c log.showSignature=false log -1 --format='%cn <%ce>')
        off=$(git -c log.showSignature=false log -1 --format='%ad %cd' --date=raw | awk '{ print $2, $4 }')
        case "$off" in
            "+0000 +0000") when="timed in UTC" ;;
            "${off% *} ${off% *}") when="with the offset ${off% *}" also=", and that offset" ;;
            *) when="with the offsets ${off% *} (author) and ${off#* } (committer)" also=", and those offsets" ;;
        esac
        if [ "$by" = "$as" ]; then
            say "created initial commit on ${DEPLOY_BRANCH} as $as, $when; a push publishes that name and address$also (mdb-guide.md, \"What a push publishes\")"
        else
            say "created initial commit on ${DEPLOY_BRANCH} as $as, committed by $by, $when; a push publishes both names and addresses$also (mdb-guide.md, \"What a push publishes\")"
        fi
    # The identity asked of git (its messages are in the user's language), the dates emptied.
    elif ! GIT_AUTHOR_DATE='' GIT_COMMITTER_DATE='' git -c user.useConfigOnly=true var GIT_AUTHOR_IDENT >/dev/null 2>&1 \
        || ! GIT_AUTHOR_DATE='' GIT_COMMITTER_DATE='' git -c user.useConfigOnly=true var GIT_COMMITTER_IDENT >/dev/null 2>&1; then
        warn "no first commit ($(printf '%s\n' "$err" | grep -m 1 . || true)). Set user.name and user.email (mdb-guide.md, \"What a push publishes\"), then: git add -A && TZ=UTC git commit -m 'initial mdBook scaffold'"
    else
        warn "no first commit; git says:"
        printf '%s\n' "$err" | sed 's/^/    /' >&2
        echo "Fix what it says, then: git add -A && TZ=UTC git commit -m 'initial mdBook scaffold'" >&2
    fi
}

# First free port from $1 on 127.0.0.1 (where both servers bind), via /dev/tcp.
pick_port() {
    local port=$1
    while (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; do
        port=$((port + 1))
    done
    printf '%s' "$port"
}

## ─── file writers ────────────────────────────────────────────────────────
# src/ and README.md are the author's; everything else here is script-owned.

# book.toml regenerates every run; src/ is written once, when SUMMARY.md is missing.
write_book_files() {
    render_book_toml > book.toml
    if [ -f src/SUMMARY.md ]; then
        say "regenerated book.toml; src/ preserved"
        return
    fi
    mkdir -p src
    render_summary  > src/SUMMARY.md
    render_about    > src/about.md
    render_chapter  > src/chapter_1.md
    render_demo_svg > src/demo.svg
    say "wrote book.toml, src/SUMMARY.md, src/about.md, src/chapter_1.md, src/demo.svg"
}

write_gitignore() {
    local l
    for l in bin/ book/ .preview/; do
        grep -qxF "$l" .gitignore 2>/dev/null && continue
        # On a line of its own: a last line without its newline would take it.
        [ ! -s .gitignore ] || [ -z "$(tail -c 1 .gitignore)" ] || echo >> .gitignore
        echo "$l" >> .gitignore
    done
}

# FAVICON_TEXT -> the characters drawn: "auto", the title's first letter or digit; else up to 3.
favicon_mark() {
    local mark="" i c
    if [ "$FAVICON_TEXT" = "auto" ]; then
        for ((i = 0; i < ${#BOOK_TITLE}; i++)); do
            c="${BOOK_TITLE:i:1}"
            if [[ "$c" == [[:alnum:]] ]]; then
                mark="${c^^}"
                break
            fi
        done
        [ -n "$mark" ] || warn "FAVICON_TEXT is \"auto\" but BOOK_TITLE has no letter or digit: no favicon is drawn, and mdBook's own stays"
    else
        mark="${FAVICON_TEXT:0:3}"
        if [ "${#FAVICON_TEXT}" -gt 3 ]; then
            warn "FAVICON_TEXT is ${#FAVICON_TEXT} characters and a favicon is read at 16px; only \"${mark}\" is drawn"
        fi
    fi
    printf '%s' "$mark"
}

# Letters in the palette's ink, dark or light by the OS: the tab bar follows the OS, not the book.
render_favicon_svg() {
    local mark="$1" size baseline
    case "${#mark}" in
        1) size=72; baseline=75 ;;
        2) size=50; baseline=68 ;;
        *) size=36; baseline=63 ;;
    esac
    cat <<EOF
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <style>
    text{font-family:"Charter","Bitstream Charter",Palatino,"Palatino Linotype","Book Antiqua","Noto Serif","Liberation Serif",Georgia,serif;font-weight:700;fill:#191713}
    @media (prefers-color-scheme: dark){text{fill:#e8e6da}}
  </style>
  <text x="50" y="${baseline}" text-anchor="middle" font-size="${size}">$(esc_xml "$mark")</text>
</svg>
EOF
}

# theme/ files replace mdBook's own; favicon.svg is never deleted, so a hand-made one survives.
write_theme() {
    # Cleared first, so a change of THEME or OFFLINE leaves nothing stale.
    rm -f theme/head.hbs theme/fonts/fonts.css
    local mark=""
    if [ -f src/logo.svg ]; then
        mkdir -p theme
        cp src/logo.svg theme/favicon.svg
        say "wrote theme/favicon.svg (from src/logo.svg)"
        # Say which source won, so an ignored FAVICON_TEXT is no puzzle.
        if [ -n "$FAVICON_TEXT" ]; then
            say "  (src/logo.svg wins; FAVICON_TEXT \"${FAVICON_TEXT}\" is not drawn)"
        fi
    # favicon_mark runs only on this branch, so its warnings fire only when used.
    elif mark=$(favicon_mark); [ -n "$mark" ]; then
        mkdir -p theme
        render_favicon_svg "$mark" > theme/favicon.svg
        say "wrote theme/favicon.svg (drawn from FAVICON_TEXT: \"${mark}\")"
    fi
    # Before mdBook's script reads it: books on one github.io origin share localStorage.
    local head
    mkdir -p theme
    if [ "$THEME" = picker ]; then
        head="THEME \"picker\": clears a saved theme the button doesn't offer"
        cat > theme/head.hbs <<'EOF'
{{!-- Script-owned. Clears a saved theme the button doesn't offer: books on one github.io share it. --}}
<script>try{const t=localStorage.getItem('mdbook-theme');if(t!==null&&t!=='light'&&t!=='ayu')localStorage.removeItem('mdbook-theme');}catch(e){}</script>
EOF
    else
        head="THEME \"$THEME\": clears a saved theme"
        cat > theme/head.hbs <<'EOF'
{{!-- Script-owned. Clears a saved theme: no button, and books on one github.io share it. --}}
<script>try{localStorage.removeItem('mdbook-theme');}catch(e){}</script>
EOF
    fi
    # Night modes built on Dark Reader (Brave's and Firefox's on iOS) repaint both palettes, Light into olive, unless locked out.
    head="$head; locks out Dark Reader night modes"
    cat >> theme/head.hbs <<'EOF'
{{!-- Script-owned. Night modes built on Dark Reader leave the book's own palettes alone. --}}
<meta name="darkreader-lock">
EOF
    if [ "$OFFLINE" = full ]; then
        head="$head; links the web app manifest"
        cat >> theme/head.hbs <<'EOF'
{{!-- Script-owned (OFFLINE="full"): the web app manifest offline.py writes. --}}
<link rel="manifest" href="{{ path_to_root }}manifest.webmanifest">
EOF
    fi
    say "wrote theme/head.hbs (${head})"
    mkdir -p theme/fonts
    cat > theme/fonts/fonts.css <<'EOF'
/* Script-owned. No faces: its presence stops mdBook shipping Open Sans and Source Code Pro. */
EOF
    say "wrote theme/fonts/fonts.css"
}

# All four Charter faces present: only then is @font-face written (else 404s).
charter_present() {
    local f
    for f in charter_regular charter_italic charter_bold charter_bold_italic; do
        [ -f "src/fonts/${f}.woff2" ] || return 1
    done
    return 0
}

# custom.css, always written: the reading theme and palette, and the look of what custom.js adds.
write_custom_css() {
    # The palette's selectors: the class in each is load-bearing (mdb-guide.md, "Light and dark").
    local dark_sel="html.light,html.ayu" print_sel="html.light,html.ayu" light
    [ "$THEME" != dark ] || { dark_sel="html.ayu"; print_sel="html.ayu"; }
    [ "$THEME" != picker ] || print_sel="html.light,html.ayu,html.js.light"
    : > custom.css
    mkdir -p src/fonts
    if [ "$THEME" != picker ]; then
        cat >> custom.css <<'EOF'
/* No theme button: theme/head.hbs clears a saved theme. */
#mdbook-theme-toggle { display: none; }
EOF
    else
        cat >> custom.css <<'EOF'
/* The theme menu's rows are buttons, which don't take the page's typeface. */
.theme-popup .theme { font-family: inherit; }
EOF
    fi
    if charter_present; then
        cat >> custom.css <<'EOF'

/* Self-hosted Charter; src/fonts/ is copied as is, not hashed. */
/* The files' Windows line metrics, for every system: the set others read has the descent's sign wrong, setting text 0.24em low. */
@font-face{font-family:"Charter";src:url("fonts/charter_regular.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap;ascent-override:98%;descent-override:23.6%;line-gap-override:0%}
@font-face{font-family:"Charter";src:url("fonts/charter_italic.woff2") format("woff2");font-weight:400;font-style:italic;font-display:swap;ascent-override:98%;descent-override:23.6%;line-gap-override:0%}
@font-face{font-family:"Charter";src:url("fonts/charter_bold.woff2") format("woff2");font-weight:700;font-style:normal;font-display:swap;ascent-override:98%;descent-override:23.6%;line-gap-override:0%}
@font-face{font-family:"Charter";src:url("fonts/charter_bold_italic.woff2") format("woff2");font-weight:700;font-style:italic;font-display:swap;ascent-override:98%;descent-override:23.6%;line-gap-override:0%}
EOF
    else
        warn "src/fonts/ has no Charter woff2, so the fallback face renders: put the four from practicaltypography.com/charter.html in src/fonts/ and re-run."
        cat >> custom.css <<'EOF'

/* No @font-face: the Charter woff2 aren't in src/fonts/. */
EOF
    fi
    cat >> custom.css <<'EOF'

/* 1rem = 10px (mdBook's 62.5%). Everything tied to the bar reads --menu-bar-height. */
:root{
  --serif: "Charter","Bitstream Charter",Palatino,"Palatino Linotype","Book Antiqua","Noto Serif","Liberation Serif",Georgia,serif;
  --content-max-width: 700px;
  --menu-bar-height: 60px;
  --mono-font: ui-monospace,"DejaVu Sans Mono","Liberation Mono",Menlo,Consolas,monospace;
}
html{font-family:var(--serif);}
.sidebar{font-size:1.6rem;}
.content{font-size:1.9rem;line-height:1.55;}

/* Headings: margins in em of each one's own size; H4 to H6 at text size, told apart by style. */
.content h1{font-size:3.4rem;line-height:1.18;margin:2.4rem 0;padding-bottom:1.3rem;border-bottom:1px solid var(--rule-strong);}
.content h2{font-size:2.5rem;line-height:1.18;margin:1.5em 0 .4em;}
.content h3{font-size:2.15rem;line-height:1.18;margin:1.18em 0 .29em;}
.content h4{font-size:1em;line-height:1.18;font-style:italic;margin:1.4em 0 .3em;}
.content h5{font-size:1em;line-height:1.18;font-style:italic;font-weight:normal;margin:1.3em 0 .3em;}
.content h6{font-size:1em;line-height:1.18;font-style:italic;font-weight:normal;color:var(--muted);margin:1.1em 0 .3em;}
.content h6 .header:is(:link,:visited){color:inherit;}

/* What follows a heading starts right under it; a heading after a heading keeps its space. */
.content main :is(h2,h3,h4,h5,h6) + :not(h2,h3,h4,h5,h6),.content main :is(h2,h3,h4,h5,h6) + :not(h2,h3,h4,h5,h6) > :first-child{margin-block-start:0;}

/* A --- straight after a heading, or its subtitle, underlines it. */
.content main :is(h2,h3,h4,h5,h6):has(+ hr),.content main :is(h2,h3,h4,h5,h6) + .mdb-subtitle:has(+ hr){margin-block-end:.3em;}
.content main :is(h2,h3,h4,h5,h6) + hr,.content main :is(h2,h3,h4,h5,h6) + .mdb-subtitle + hr{margin-block:0 1.6rem;}

/* Subtitle: <p class="mdb-subtitle"> straight under a heading. */
.content .mdb-subtitle{margin-block:0 0;font-size:1.9rem;font-style:italic;line-height:1.35;color:var(--muted);}
.content h2:has(+ .mdb-subtitle){margin-block-end:.25em;}

/* Title block: the H1 and its subtitle; one rule closes it, the Last updated line under it. */
.content h1:has(+ .mdb-subtitle){margin-block-end:.25em;padding-bottom:0;border-bottom:0;}
.content h1 + .mdb-subtitle{margin-block-end:2.4rem;padding-bottom:1.3rem;border-bottom:1px solid var(--rule-strong);}
.content h1:has(+ .mdb-updated),.content h1 + .mdb-subtitle:has(+ .mdb-updated){margin-block-end:0;}
.content .mdb-updated{margin-block-end:2.4rem;}

/* Blockquote, not admonitions (.blockquote-tag); edge margins zeroed so the rule sits flush. */
.content blockquote:not(.blockquote-tag){background:none;border-block:0;border-inline-start:3px solid var(--rule);padding:0 1em;color:var(--muted);}
.content blockquote:not(.blockquote-tag) > :first-child{margin-block-start:0;}
.content blockquote:not(.blockquote-tag) > :last-child{margin-block-end:0;}

/* Aside: a muted digression with a thin left rule. */
.content aside{font-size:.82em;color:var(--muted);border-inline-start:2px solid var(--rule);padding-inline-start:1em;margin:1.15rem 0;}

/* Footnotes, muted like the aside; mdBook draws the <hr> above them. */
.content .footnote-definition{font-size:.82em;color:var(--muted);}
/* Links in notes muted too: the underline marks them, not the colour. */
.content .footnote-definition a:link,.content .footnote-definition a:visited{color:var(--muted);}

/* Underlined links; an <a> with no href is text, on hover and in print too. Followed: --visited. */
.content a:any-link{text-decoration:underline;text-underline-offset:2px;}
.content a:not(:any-link){color:inherit;text-decoration:none;}
.content a:not(:any-link) > code.hljs{background-color:var(--inline-bg);color:var(--inline-code-color);box-shadow:inset 0 0 0 1px var(--inline-border);text-decoration:none;}
#mdbook-searchresults a{text-decoration:underline;text-underline-offset:2px;}
.content main a:visited{color:var(--visited);}
.content .footnote-reference a:visited{color:var(--links);}

/* A numbered list in a numbered list counts 1.1, 1.2.1; a restarted list (<ol start>) keeps its own. */
.content main ol{counter-reset:outline;}
.content main ol > li{counter-increment:outline;}
.content main ol ol > li{list-style:none;position:relative;}
.content main ol ol > li::before{content:counters(outline,".");position:absolute;inset-inline-end:100%;padding-inline-end:.25em;}
.content main ol[start] ol > li,.content main ol ol[start] > li{list-style:revert;}
.content main ol[start] ol > li::before,.content main ol ol[start] > li::before{content:none;}

/* Palette: every colour mdBook paints, the dark block first (mdb-guide.md, "Light and dark"). */
EOF
    cat >> custom.css <<EOF
${dark_sel}{
  --bg:#111;--fg:#e8e6da;--links:#e8e6da;--visited:#94b9dc;--inline-code-color:#ffb454;--inline-bg:#2b281f;--inline-border:#3a382f;--muted:#aaa8a0;
  --rule:#2c2c28;--rule-strong:#42423c;--color-scheme:dark;
  --sidebar-bg:#161613;--sidebar-fg:#cfcdc2;--sidebar-active:#ffb454;
  --sidebar-spacer:#42423c;--sidebar-non-existant:#6b6a61;--sidebar-header-border-color:#42423c;
  --icons:#7a786e;--icons-hover:#e8e6da;
  --copy-button-filter:invert(1) opacity(.55);--copy-button-filter-hover:invert(1) opacity(1);
  --quote-bg:#1b1b18;--quote-border:#2c2c28;--warning-border:#ffb454;--code-bg:#242420;--code-comment:#8b949e;
  --table-border-color:#2c2c28;--table-header-bg:#2c2c28;--table-alternate-bg:#191916;
  --searchbar-bg:#1b1b18;--searchbar-fg:#e8e6da;--searchbar-border-color:#42423c;--searchbar-shadow-color:#42423c;
  --searchresults-border-color:#2c2c28;--searchresults-header-fg:#9a988c;--searchresults-li-bg:#191916;
  --search-mark-bg:#5a4a24;--footnote-highlight:#5a4a24;--overlay-bg:rgba(10,10,8,.7);
  --theme-popup-bg:#161613;--theme-popup-border:#676660;--theme-hover:#1b1b18;
}
EOF
    if [ "$THEME" != dark ]; then
        # "only light", not "light": Chrome's dark theme for sites then leaves a reader's Light alone.
        light=$(cat <<'EOF'
  --bg:#fdfcf7;--fg:#191713;--links:#191713;--visited:#295a8e;--inline-code-color:#9d4909;--inline-bg:#ece3cd;--inline-border:#e0d8c2;--muted:#57564e;
  --rule:#e5e3d7;--rule-strong:#c2bda4;--color-scheme:only light;
  --sidebar-bg:#f8f6ee;--sidebar-fg:#3a3a34;--sidebar-active:#9d4909;
  --sidebar-spacer:#c2bda4;--sidebar-non-existant:#a8a496;--sidebar-header-border-color:#c2bda4;
  --icons:#7d7a6e;--icons-hover:#191713;
  --copy-button-filter:opacity(.55);--copy-button-filter-hover:opacity(1);
  --quote-bg:#f4f2e8;--quote-border:#e5e3d7;--warning-border:#9d4909;--code-bg:#f4f2e8;--code-comment:#555;
  --table-border-color:#e5e3d7;--table-header-bg:#e0dccb;--table-alternate-bg:#faf8ee;
  --searchbar-bg:#fdfcf7;--searchbar-fg:#191713;--searchbar-border-color:#c2bda4;--searchbar-shadow-color:#c2bda4;
  --searchresults-border-color:#e5e3d7;--searchresults-header-fg:#57564e;--searchresults-li-bg:#f9f7ec;
  --search-mark-bg:#f2d9a8;--footnote-highlight:#f2d9a8;--overlay-bg:rgba(20,19,15,.6);
  --theme-popup-bg:#f8f6ee;--theme-popup-border:#c2bda4;--theme-hover:#e8e6d9;--scrollbar:var(--sidebar-fg);
EOF
)
        printf '@media (prefers-color-scheme: light){\nhtml.light{\n%s\n}\n}\n' "$light" >> custom.css
        [ "$THEME" != picker ] || printf 'html.js.light{\n%s\n}\n' "$light" >> custom.css
        # html.js.light: the light highlight sheet is live under either selector above.
        cat >> custom.css <<'EOF'
/* The light sheet's green strings and additions, a shade darker for 4.5:1 on the code ground. */
@media screen{
html.js.light pre > code.hljs :is(.hljs-string,.hljs-symbol,.hljs-bullet){color:#007f00;}
html.js.light pre > code.hljs .hljs-addition{color:#207d36;}
}
EOF
    fi
    cat >> custom.css <<EOF

/* Code-block ground from the palette (mdBook paints it from the highlight sheet). */
pre > code.hljs{background-color:var(--code-bg);}

/* Comments from the palette (ayu's are 2.7:1 here); !important, so print keeps it too. */
pre > code.hljs .hljs-comment,pre > code.hljs .hljs-quote{color:var(--code-comment) !important;}
/* Ayu's tag punctuation (< = >) too: 2:1 as it is. On screen: print has code in ink. */
@media screen{html.ayu pre > code.hljs .hljs-tag{color:var(--code-comment);}}

/* Inline code: a warm fill, accent text, and a hairline as an inset shadow (no metric change). */
:not(pre):not(a) > code.hljs{background-color:var(--inline-bg);color:var(--inline-code-color);box-shadow:inset 0 0 0 1px var(--inline-border);}

/* Marks in the palette's ink; a hit in code, marked before highlight.js runs, inherits the mark. */
mark{color:var(--fg);}
pre > code.hljs mark span{background-color:transparent;}
@media screen{pre > code.hljs mark :is(span,.hljs-comment){color:inherit !important;}}

/* Mobile chapter buttons sit on the page, so they take the quote ground. */
.mobile-nav-chapters{background-color:var(--quote-bg);}

/* Print: ink on paper, whatever the theme; the palette's selectors, or it never applies. */
@media print{
  ${print_sel}{
       --bg:#fff;--fg:#111;--links:#111;--visited:#111;--inline-code-color:#111;--inline-bg:#f5f5f5;--inline-border:#ddd;--muted:#444;
       --rule:#ccc;--rule-strong:#999;--quote-bg:#fff;--quote-border:#ccc;--code-bg:#f5f5f5;--code-comment:#555;
       --table-border-color:#ccc;--table-header-bg:#eee;--table-alternate-bg:#fff;
       --blockquote-note-color:#0969da;--blockquote-tip-color:#008000;--blockquote-important-color:#8250df;--blockquote-warning-color:#9a6700;--blockquote-caution-color:#b52731;
       --warning-border:#9a6700;--search-mark-bg:#f2d9a8;}
  a > code.hljs{background-color:var(--inline-bg);}
}
EOF

    # The rest: icon buttons, code in print, touch swipes, the columns' top gap, SUMMARY rules,
    # unlisted rows, .mdb-wide and .mdb-bleed (mdb-guide.md, "Wide pages and wide blocks").
    cat >> custom.css <<'EOF'
#mdbook-menu-bar .icon-button{font:inherit;line-height:var(--menu-bar-height);}
@media (any-pointer:coarse){html{overscroll-behavior-x:none;}}
@media print{pre > code.hljs{background-color:#f5f5f5;}pre > code.hljs,pre > code.hljs :is(span,mark){color:#111 !important;}pre > code.hljs :is(.hljs-comment,.hljs-quote) span{color:inherit !important;}pre > code.hljs mark{background-color:#f2d9a8;}pre > code.hljs mark span{background-color:transparent;}}

:root{--mdb-top-gap:2.4rem;}
.content main > :first-child{margin-block-start:var(--mdb-top-gap);}
.chapter{margin-block-start:0;}
.chapter > li:first-child{margin-block-start:0;}
.chapter > li.chapter-item:empty:first-child + li{margin-block-start:0;}
.chapter li.chapter-item:empty{margin-block:0;}
.chapter li.part-title{line-height:1.5em;margin-block:1.7em .55em;padding-block-end:.3em;border-block-end:1px solid var(--rule-strong);}
.chapter li.chapter-item:has(a[href^="unlisted/"],a[href*="/unlisted/"]){display:none;}

.content hr{border:0;height:1px;background-color:var(--rule-strong);margin:3.2rem 0;}
.chapter .spacer{height:1px;margin-block:1.6em;}

.sidebar .mdb-sitemeta{margin-top:1rem;padding:1rem 0 .5rem;border-top:1px solid var(--rule);font-size:1.3rem;line-height:1.5;color:var(--sidebar-fg);opacity:.8;}
.sidebar .mdb-sitemeta a{color:inherit;text-decoration:underline dotted;text-underline-offset:3px;}

/* The per-page Last updated line, hanging under the title block's rule. */
.content .mdb-updated{margin-block-start:1rem;font-size:.9em;font-style:italic;color:var(--muted);}

.mdb-print-back{margin:0 0 2rem;font-size:.85em;}
@media print{.mdb-print-back{display:none;}}

@media screen{
  .content main:has(.mdb-wide){max-width:1000px;}
  .content{container-type:inline-size;}
  .content main > .mdb-bleed{box-sizing:border-box;clear:both;max-width:none;width:min(1000px,100cqw);margin-inline:calc((100% - min(1000px,100cqw)) / 2);}
  :where(.content main > .mdb-bleed:not(table):not([hidden])){display:block;}
}
EOF
    if [ "$SIDEBAR_MASTHEAD" = none ]; then
        # No masthead: padding alone holds the first rows level.
        cat >> custom.css <<'EOF'
.sidebar .sidebar-scrollbox{padding-top:calc(var(--menu-bar-height) + 1px + var(--mdb-top-gap));}
EOF
    else
        # Masthead band: sticky and opaque, with a hairline once the list scrolls under it.
        cat >> custom.css <<'EOF'
.sidebar .sidebar-scrollbox{padding-top:0;}
.sidebar-scrollbox::before{position:sticky;top:0;box-sizing:border-box;background-color:var(--sidebar-bg);border-block-end:1px solid transparent;}
.sidebar-scrollbox.mdb-scrolled::before{border-block-end-color:var(--table-border-color);}
.menu-title{transition:opacity .3s;}
html.sidebar-visible .menu-title{opacity:0;}
EOF
        if [ "$SIDEBAR_MASTHEAD" = text ]; then
            # BOOK_TITLE as text, not an SVG: an image can't load the webfont.
            cat >> custom.css <<EOF
.sidebar-scrollbox::before{content:"$(esc_dq "$BOOK_TITLE")";display:block;height:calc(var(--menu-bar-height) + 1px);margin-block-end:var(--mdb-top-gap);font-size:2.7rem;font-weight:200;line-height:calc(var(--menu-bar-height) - 4px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
EOF
        else
            # src/logo.svg as a mask on a layer of its own: on the sticky plate it would clip it.
            [ -f src/logo.svg ] || warn "SIDEBAR_MASTHEAD is \"image\" but src/logo.svg is missing; the sidebar shows an empty band until you add it"
            cat >> custom.css <<'EOF'
.sidebar-scrollbox::before{content:"";display:block;height:calc(var(--menu-bar-height) + 1px);margin-block-end:var(--mdb-top-gap);}
html.js .sidebar::after{content:"";position:absolute;top:0;left:10px;right:10px;height:calc(var(--menu-bar-height) + 1px);pointer-events:none;background-color:var(--sidebar-fg);-webkit-mask:url("logo.svg") no-repeat left center/auto 50%;mask:url("logo.svg") no-repeat left center/auto 50%;}
EOF
        fi
    fi
    if [ "$CODE_LINE_NUMBERS" = true ]; then
        # The gutter sits beside <code>, never in it, so the copy button copies the source exactly.
        cat >> custom.css <<'EOF'
pre:has(> .mdb-gutter){display:flex;align-items:stretch;}
pre > .mdb-gutter{flex:0 0 auto;-webkit-user-select:none;user-select:none;white-space:pre;text-align:right;font-family:var(--mono-font);font-size:var(--code-font-size);padding:1rem .8em 1rem 1rem;background-color:var(--code-bg);color:var(--muted);border-right:1px solid var(--rule);}
pre > .mdb-gutter + code.hljs{flex:1 1 auto;min-width:0;padding-left:1em;}
@media print{
  pre:has(> .mdb-gutter){display:block;}
  pre > .mdb-gutter{display:none;}
  pre > .mdb-gutter + code.hljs{padding-left:1rem;}
}
EOF
    fi
    if [ "$LAYOUT" != scroll ]; then
        cat >> custom.css <<'EOF'
/* LAYOUT: the sections not shown are hidden on screen only; the sidebar's mark is ours. */
@media screen{html.mdb-pages #mdbook-content main .mdb-off{display:none !important;}}
html.mdb-pages:not(.mdb-whole) .chapter a.current-header:not(.mdb-here):not(:hover){color:var(--sidebar-fg);}
html.mdb-pages .chapter a.mdb-here{color:var(--sidebar-active);}
.mdb-pageno{display:none;}
html.mdb-pages .mdb-pageno{display:block;margin:2.5rem 0 0;text-align:center;font-size:.85em;color:var(--muted);}
@media print{.mdb-pageno,#mdb-layout-toggle,.mdb-layout-menu{display:none !important;}}
.mdb-layout-menu{position:absolute;top:var(--menu-bar-height);z-index:1000;box-sizing:border-box;width:28rem;max-width:calc(100vw - 16px);margin:0;padding:.4rem 0;border-radius:4px;background:var(--theme-popup-bg);border:1px solid var(--theme-popup-border);}
.mdb-layout-menu[hidden]{display:none;}
.mdb-layout-item{position:relative;display:block;width:100%;margin:0;padding:.8rem 1.4rem .9rem;padding-inline-start:3.6rem;border:0;background:none;color:var(--fg);font:inherit;line-height:1.4;text-align:start;cursor:pointer;}
.mdb-layout-item:hover,.mdb-layout-item:focus-visible{background:var(--theme-hover);}
/* The tick in the text's colour, as mdBook's own theme menu draws it. */
.mdb-layout-item[aria-pressed="true"]::before{content:"\2713";content:"\2713" / "";position:absolute;inset-inline-start:1.3rem;top:.8rem;color:var(--fg);}
.mdb-layout-name{display:block;font-size:1.5rem;font-weight:bold;}
.mdb-layout-note{display:block;margin-top:.2rem;font-size:1.3rem;color:var(--muted);}
/* mdBook's » marks the heading an address leads to: in Pages, every section's own. */
html.mdb-pages #mdbook-content main > :is(h1,h2,h3,h4,h5,h6):target::before{content:none;}
EOF
    fi
    if [ "$OFFLINE" != none ]; then
        cat >> custom.css <<'EOF'
/* OFFLINE: the last button on the right, after print, and its panel; a dot on the button: a newer version is out. */
/* Its icon's 8px a side, as print's and the pencil's: mdBook's buttons have 8px more. */
#mdbook-menu-bar #mdb-offline-toggle{position:relative;padding:0;}
#mdb-offline-toggle.mdb-offline-wait{visibility:hidden;}
#mdb-offline-toggle.mdb-offline-new::after{content:"";position:absolute;top:calc(50% - 1.2rem);right:-5px;width:.8rem;height:.8rem;border-radius:50%;background:var(--sidebar-active);}
#mdb-offline-toggle.mdb-offline-busy svg{animation:mdb-offline-spin 1.6s linear infinite;}
@keyframes mdb-offline-spin{to{transform:rotate(360deg);}}
@media (prefers-reduced-motion:reduce){#mdb-offline-toggle.mdb-offline-busy svg{animation:none;}}
.mdb-offline-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;}
.mdb-offline-panel{position:absolute;top:var(--menu-bar-height);right:10px;z-index:1000;box-sizing:border-box;width:36rem;max-width:calc(100vw - 20px);max-height:calc(100vh - var(--menu-bar-height) - 20px);max-height:calc(100dvh - var(--menu-bar-height) - 20px);overflow-y:auto;overscroll-behavior:contain;padding:0 1.6rem 1.6rem;border:1px solid var(--theme-popup-border);border-radius:4px;background:var(--theme-popup-bg);color:var(--fg);font-size:1.5rem;line-height:1.4;text-align:start;}
[dir=rtl] .mdb-offline-panel{right:auto;left:10px;}
.mdb-offline-panel[hidden]{display:none;}
.mdb-offline-panel p{margin:1rem 0 0;}
.mdb-offline-panel .mdb-offline-title{margin-top:1.2rem;font-weight:bold;}
.mdb-offline-panel .mdb-offline-note,.mdb-offline-panel .mdb-offline-sum{font-size:1.3rem;color:var(--muted);}
.mdb-offline-panel .mdb-offline-error{font-style:italic;}
.mdb-offline-panel a{color:inherit;text-decoration:underline;}
.mdb-offline-panel .mdb-offline-sum code{font-size:1.2rem;word-break:break-all;}
.mdb-offline-panel progress{display:block;width:100%;margin-top:1rem;accent-color:var(--sidebar-active);}
.mdb-offline-panel .mdb-offline-actions{display:flex;flex-wrap:wrap;gap:.8rem;margin-top:1.2rem;}
.mdb-offline-panel .mdb-offline-act{padding:.5rem 1.2rem;font:inherit;color:var(--fg);background:var(--theme-hover);border:1px solid var(--theme-popup-border);border-radius:4px;cursor:pointer;}
.mdb-offline-panel .mdb-offline-act:disabled{opacity:.5;cursor:default;}
/* Save, Update and the download: a big button each, what it's for under it. */
.mdb-offline-panel .mdb-offline-big{display:block;box-sizing:border-box;width:100%;margin-top:1.4rem;padding:1rem 1.2rem;border:1px solid var(--theme-popup-border);border-radius:6px;background:var(--inline-bg);color:var(--fg);font:inherit;font-size:1.6rem;font-weight:bold;line-height:1.3;text-align:center;text-decoration:none;cursor:pointer;}
.mdb-offline-panel .mdb-offline-big:is(:hover,:focus-visible):not(:disabled){border-color:var(--sidebar-active);}
.mdb-offline-panel .mdb-offline-big:disabled{opacity:.5;cursor:default;}
.mdb-offline-panel .mdb-offline-what{margin-top:.6rem;}
.mdb-offline-panel .mdb-offline-what + .mdb-offline-note{margin-top:.2rem;}
/* The download under saving, a rule between them. */
.mdb-offline-panel .mdb-offline-sep{margin-top:1.6rem;border-top:1px solid var(--theme-popup-border);}
/* A saved copy behind the site: the line above the page's text, outside <main>. */
.mdb-stale{box-sizing:border-box;max-width:var(--content-max-width);margin:var(--mdb-top-gap) auto 0;padding:.8rem 1.2rem;border-inline-start:3px solid var(--sidebar-active);background:var(--quote-bg);font-size:1.6rem;line-height:1.45;}
@media print{#mdb-offline-toggle,.mdb-offline-panel,.mdb-stale{display:none;}}
EOF
    fi
    say "wrote custom.css"
}

# custom.js, always written: the sidebar's key and swipes, links, print, the footer and dates.
write_custom_js() {
    cat > custom.js <<EOF
// Script-owned. MDB_REPO comes from GIT_REPO_URL; the deploy stamp is in version.js.
var MDB_REPO = "$(esc_dq "$GIT_REPO_URL")";
EOF
    [ "$OFFLINE" = none ] || echo "var MDB_OFFLINE = \"${OFFLINE}\";" >> custom.js
    cat >> custom.js <<'EOF'
(function () {
  var ready = function (fn) {
    if (document.readyState === "loading")
      document.addEventListener("DOMContentLoaded", fn);
    else fn();
  };

  // mdBook's print.html, at the book's root (a print.md in a folder is a page like any other).
  var isPrint = function () {
    return /(^|\/)print\.html$/.test(location.pathname) &&
      (typeof path_to_root !== "string" || path_to_root === "");
  };

  // A stamped value: non-empty and no longer a placeholder.
  var stamped = function (v) {
    return typeof v === "string" && v !== "" && v.indexOf("__MDB_") !== 0;
  };

  // Relative time by local calendar day ("just now", "yesterday", then the date); exact on hover.
  var mdbWhen = function (iso, now) {
    var d = new Date(iso);
    if (isNaN(d)) return null;
    now = now || new Date();
    var ms = now - d;
    if (ms < 0) ms = 0;
    var day = function (x) { return new Date(x.getFullYear(), x.getMonth(), x.getDate()); };
    var days = Math.round((day(now) - day(d)) / 86400000);
    var text;
    if (days <= 0) {
      var h = Math.floor(ms / 3600000);
      text = h < 1 ? "just now" : h === 1 ? "1 hour ago" : h + " hours ago";
    } else if (days === 1) text = "yesterday";
    else if (days === 2) text = "two days ago";
    else if (days === 3) text = "three days ago";
    else {
      var p = function (n) { return (n < 10 ? "0" : "") + n; };
      text = d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate());
    }
    var t = document.createElement("time");
    t.dateTime = iso;
    t.title = iso;
    t.textContent = text;
    return t;
  };

  // 'b' toggles the sidebar, behind the same focus guard mdBook uses.
  document.addEventListener("keydown", function (e) {
    if (e.ctrlKey || e.altKey || e.metaKey) return;
    if (e.key !== "b") return;
    var t = (e.composedPath && e.composedPath()[0]) || e.target;
    if (!t) return;
    if (t.isContentEditable) return;
    if (/^(input|textarea|select)$/i.test(t.tagName || "")) return;
    var btn = document.getElementById("mdbook-sidebar-toggle");
    if (btn) { btn.click(); e.preventDefault(); }
  });

  // Swipes, through the sidebar's checkbox: mdBook 0.5.4's set a class and move nothing.
  (function () {
    var box = document.getElementById("mdbook-sidebar-toggle-anchor");
    var bar = document.getElementById("mdbook-sidebar");
    if (!box || !bar) return;
    var set = function (on) {
      if (box.checked === on) return;
      // As mdBook's own toggle does: back on screen first, so it slides in.
      if (on) { bar.style.display = ""; void bar.offsetHeight; }
      box.checked = on;
      box.dispatchEvent(new Event("change"));
    };
    var start = null;
    document.addEventListener("touchstart", function (e) {
      var t = e.target;
      start = e.touches.length === 1 && !(t.closest && t.closest("pre, table, .table-wrapper, input, select, textarea, .theme-popup, .checkbox-img:checked ~ .img-wrapper, #mdbook-help-container, .mdb-menu")) ?
        { x: e.touches[0].clientX, y: e.touches[0].clientY, time: Date.now() } : null;
    }, { passive: true });
    document.addEventListener("touchmove", function (e) {
      if (start) {
        var x = e.touches[0].clientX, dx = x - start.x, dy = e.touches[0].clientY - start.y;
        if (Date.now() - start.time < 250 && Math.abs(dx) >= 150) {
          if (Math.abs(dx) > Math.abs(dy)) {
            if (dx >= 0 && start.x < Math.min(document.body.clientWidth * 0.25, 300)) set(true);
            else if (dx < 0 && x < 300) set(false);
          }
          start = null;
        }
      }
      // mdBook's handler took the gestures this one passes over: keep its state in step.
      if (document.documentElement.classList.contains("sidebar-visible") !== box.checked)
        box.dispatchEvent(new Event("change"));
    }, { passive: true });
  })();

  // Keep mdBook's '?' shortcut popup honest about the key we just added.
  ready(function () {
    var help = document.querySelector("#mdbook-help-popup > div");
    if (!help) return;
    var p = document.createElement("p");
    var k = document.createElement("kbd");
    k.textContent = "b";
    p.appendChild(document.createTextNode("Press "));
    p.appendChild(k);
    p.appendChild(document.createTextNode(" to toggle the sidebar"));
    help.appendChild(p);
  });

  // Off-site links open in a new tab.
  ready(function () {
    var here = location.origin;
    var links = document.querySelectorAll(".content a[href]");
    for (var i = 0; i < links.length; i++) {
      var a = links[i], u;
      try { u = new URL(a.href, location.href); } catch (err) { continue; }
      if (u.origin !== here) { a.target = "_blank"; a.rel = "noopener noreferrer"; }
    }
  });

  // The pencil in front of print, so print and the offline button, last, go together; a space apart, as mdBook's.
  ready(function () {
    var right = document.querySelector("#mdbook-menu-bar .right-buttons");
    var print = right && right.querySelector('a[href$="print.html"]'), edit = right && right.querySelector('a[rel="edit"]');
    if (!print || !edit) return;
    right.insertBefore(edit, print);
    right.insertBefore(document.createTextNode(" "), print);
  });

  // Remove (not hide) prev/next links into src/unlisted/: book.js follows a hidden one too.
  ready(function () {
    var sel = 'a[class*="nav-chapters"][href^="unlisted/"],' +
              'a[class*="nav-chapters"][href*="/unlisted/"]';
    var links = document.querySelectorAll(sel);
    for (var i = 0; i < links.length; i++) links[i].remove();
  });

  // The masthead's hairline once the list scrolls under it.
  ready(function () {
    var box = document.querySelector(".sidebar .sidebar-scrollbox");
    if (!box) return;
    var border = function () { box.classList.toggle("mdb-scrolled", box.scrollTop > 0); };
    border();
    box.addEventListener("scroll", border, { passive: true });
  });

  // The sidebar footer; each part only if stamped (version.js).
  ready(function () {
    var box = document.querySelector(".sidebar .sidebar-scrollbox");
    if (!box) return;
    var version = window.MDB_VERSION, updated = window.MDB_UPDATED, sha = window.MDB_SHA;
    var parts = [];
    if (stamped(version)) parts.push(document.createTextNode(version));
    if (stamped(updated)) {
      // Relative, exact on hover; an unparseable stamp shows as it is.
      var u = document.createElement("span");
      u.appendChild(document.createTextNode("updated "));
      var w = mdbWhen(updated);
      if (w) u.appendChild(w);
      else u.appendChild(document.createTextNode(updated));
      parts.push(u);
    }
    if (stamped(sha)) {
      if (MDB_REPO) {
        var s = document.createElement("a");
        s.href = MDB_REPO + "/commit/" + encodeURIComponent(sha);
        s.textContent = sha;
        parts.push(s);
      } else {
        parts.push(document.createTextNode(sha));
      }
    }
    if (!parts.length) return;
    var p = document.createElement("div");
    p.className = "mdb-sitemeta";
    for (var i = 0; i < parts.length; i++) {
      if (i) p.appendChild(document.createTextNode(" · "));
      p.appendChild(parts[i]);
    }
    box.appendChild(p);
  });

  // "Last updated <when>" under the H1: the source from the edit link, else from the URL.
  ready(function () {
    var MDB_PAGE_DATES = window.MDB_PAGE_DATES;
    if (typeof MDB_PAGE_DATES !== "object" || !MDB_PAGE_DATES) return;
    if (isPrint()) return;
    var main = document.querySelector("#mdbook-content main");
    if (!main) return;
    var src = "";
    var btn = document.getElementById("git-edit-button");
    var a = btn && btn.closest ? btn.closest("a") : null;
    var href = a ? a.getAttribute("href") || "" : "";
    if (href) {
      try { href = decodeURIComponent(href); } catch (err) {}
      for (var k in MDB_PAGE_DATES) {
        if (href.slice(-(k.length + 1)) === "/" + k && k.length > src.length) src = k;
      }
    }
    if (!src) {
      var depth = 0;
      if (typeof path_to_root === "string")
        depth = (path_to_root.match(/\.\.\//g) || []).length;
      var segs = location.pathname.split("/").filter(Boolean);
      var rel = segs.slice(segs.length - 1 - depth).join("/");
      try { rel = decodeURIComponent(rel); } catch (err) {}
      if (/\.html$/.test(rel) && rel !== "index.html") {
        var cand = "src/" + rel.replace(/\.html$/, "") + ".md";
        if (MDB_PAGE_DATES[cand]) src = cand;
      }
    }
    var iso = src ? MDB_PAGE_DATES[src] : "";
    if (!iso) return;
    var w = mdbWhen(iso);
    if (!w) return;
    var head = main.querySelector("h1");
    if (!head) return;
    var after = head;
    var sib = head.nextElementSibling;
    if (sib && sib.classList && sib.classList.contains("mdb-subtitle")) after = sib;
    var p = document.createElement("p");
    p.className = "mdb-updated";
    p.appendChild(document.createTextNode("Last updated "));
    p.appendChild(w);
    after.parentNode.insertBefore(p, after.nextSibling);
  });

  // print.html: a back link, and back to the page you came from when the print dialog closes.
  (function () {
    if (!isPrint()) return;

    ready(function () {
      var main = document.querySelector("#mdbook-content main");
      if (!main) return;
      var p = document.createElement("p");
      p.className = "mdb-print-back";
      var a = document.createElement("a");
      a.href = (typeof path_to_root === "string" ? path_to_root : "") + "index.html";
      a.textContent = "← Back";
      p.appendChild(a);
      main.insertBefore(p, main.firstChild);
    });

    var left = false, entered = false;
    function leave() {
      if (left) return;
      left = true;
      if (history.length > 1) history.back();
    }
    window.addEventListener("afterprint", leave);
    if (window.matchMedia) {
      var mq = window.matchMedia("print");
      var onChange = function (e) {
        if (e.matches) entered = true;
        else if (entered) leave();
      };
      if (mq.addEventListener) mq.addEventListener("change", onChange);
      else if (mq.addListener) mq.addListener(onChange);
    }
  })();
})();
EOF
    if [ "$THEME" = picker ]; then
        cat >> custom.js <<'EOF'

// THEME "picker": the theme menu trimmed to Auto, Light and Dark; rows removed, not hidden.
(function () {
  function trim() {
    var list = document.getElementById("mdbook-theme-list");
    if (!list) return;
    ["rust", "coal", "navy"].forEach(function (name) {
      var b = document.getElementById("mdbook-theme-" + name);
      if (b && b.parentNode && b.parentNode.parentNode === list) b.parentNode.remove();
    });
    var dark = document.getElementById("mdbook-theme-ayu");
    if (dark) dark.textContent = "Dark";
  }
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", trim);
  else trim();
})();
EOF
    fi
    if [ "$CODE_LINE_NUMBERS" = true ]; then
        cat >> custom.js <<'EOF'

// Line numbers in a gutter beside <code>: language-fenced blocks of MIN_LINES or more.
(function () {
  var MIN_LINES = 10;
  function gutters() {
    var blocks = document.querySelectorAll("pre > code.hljs");
    for (var i = 0; i < blocks.length; i++) {
      var b = blocks[i];
      var pre = b.parentNode;
      if (!/(^|\s)language-\S+/.test(b.className)) continue;
      if (pre.classList.contains("playground")) continue;
      if (b.querySelector(".boring")) continue;
      var lines = b.textContent.replace(/\n+$/, "").split("\n").length;
      if (lines < MIN_LINES) continue;
      var g = document.createElement("div");
      g.className = "mdb-gutter";
      g.setAttribute("aria-hidden", "true");
      var s = "1";
      for (var n = 2; n <= lines; n++) s += "\n" + n;
      g.textContent = s;
      pre.insertBefore(g, b);
    }
  }
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", gutters);
  else gutters();
})();
EOF
    fi
    cat >> custom.js <<'EOF'

// Code that overflows sideways takes tabindex="0", so the keyboard reaches it.
(function () {
  // More than 1px: whole-pixel widths turn a sub-pixel overhang into 1px.
  var apply = function () {
    var blocks = document.querySelectorAll("pre > code.hljs");
    for (var i = 0; i < blocks.length; i++) {
      var b = blocks[i];
      if (b.scrollWidth > b.clientWidth + 1) b.setAttribute("tabindex", "0");
      else if (b.getAttribute("tabindex") === "0") b.removeAttribute("tabindex");
    }
  };
  // Re-checked on resize, debounced: a sidebar toggle fires no window resize.
  var timer = null;
  var later = function () {
    if (timer) clearTimeout(timer);
    timer = setTimeout(apply, 150);
  };
  var run = function () {
    apply();
    if (window.ResizeObserver) {
      var ro = new ResizeObserver(later);
      var blocks = document.querySelectorAll("pre > code.hljs");
      for (var i = 0; i < blocks.length; i++) ro.observe(blocks[i]);
    } else {
      window.addEventListener("resize", later);
    }
  };
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
  else run();
  // While a code block has focus, Left and Right scroll it instead of turning the page.
  document.addEventListener("keydown", function (e) {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
    var t = e.target;
    if (t && t.matches && t.matches("pre > code.hljs")) e.stopPropagation();
  }, true);
})();
EOF
    if [ "$LAYOUT" != scroll ]; then
        printf '\nvar MDB_LAYOUT = "%s";\n' "$LAYOUT" >> custom.js
        cat >> custom.js <<'EOF'
// LAYOUT (MDB_LAYOUT, above): a chapter one heading's section at a time; print has them all.
// Icons: from Font Awesome Free, the set mdBook draws its own from, with its
// notice: Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL
// OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc.
(function () {
  "use strict";
  // Not mdBook's print.html, at the book's root.
  if (/(^|\/)print\.html$/.test(location.pathname) && (typeof path_to_root !== "string" || path_to_root === "")) return;
  var main = document.querySelector("#mdbook-content main");
  var bar = document.getElementById("mdbook-menu-bar");
  if (!main || !bar) return;
  var content = document.getElementById("mdbook-content");
  // The book's own folder (on the 404 page, its <base>): books on one github.io share an origin.
  var root = new URL((typeof path_to_root === "string" ? path_to_root : "") + "./", document.baseURI).pathname;
  var KEY = "mdb-layout " + root;
  var PICK = MDB_LAYOUT === "picker";
  // The headings a section starts at.
  var STARTS = MDB_LAYOUT === "h2" ? /^H2$/ : /^H[2-6]$/;
  // An address on this page: a bare "#x" resolves against the 404 page's <base>.
  var addr = function (id) { return location.pathname + location.search + (id ? "#" + id : ""); };
  // Read once; without the picker, always Pages.
  var now = null;
  if (PICK) try { now = localStorage.getItem(KEY); } catch (e) {}
  now = !PICK || now === "pages" ? "pages" : "scroll";
  var mode = function () { return now; };
  // What needs no section of its own: the title block, and what applies to the whole chapter.
  var TITLE = "h1, .mdb-subtitle, .mdb-updated, .mdb-wide, hr, style, script, link";
  // An empty anchor (<a id="old-name"></a>, or a <span> or <div>), alone or in a paragraph.
  var EMPTY = "a[id]:empty, a[name]:empty, span[id]:empty, div[id]:empty";
  var anchor = function (n) {
    if (n.matches(EMPTY)) return true;
    return n.tagName === "P" && n.children.length > 0 && !n.textContent.trim() &&
      Array.prototype.every.call(n.children, function (c) { return c.matches(EMPTY); });
  };
  // The sections, worked out afresh each time, so lines other code adds land in the right one.
  var split = function () {
    // Text straight in <main>, or a search <mark> in it, into a <span>: it goes with its section.
    Array.prototype.slice.call(main.childNodes).forEach(function (n) {
      if (n.nodeType === 3 ? /[^ \t\n\r\f]/.test(n.nodeValue) : n.nodeName === "MARK") { var w = document.createElement("span"); main.insertBefore(w, n); w.appendChild(n); }
    });
    var pages = [[]], foot = null, rule = null;
    Array.prototype.forEach.call(main.children, function (n) {
      if (n.matches("ol.footnote-definition")) { foot = n; return; }
      if (n.matches("hr") && n.nextElementSibling && n.nextElementSibling.matches("ol.footnote-definition")) { rule = n; return; }
      if (n.matches(".mdb-pageno")) return;
      if (STARTS.test(n.tagName) && n.id) {
        var prev = pages[pages.length - 1], lead = [];
        while (prev.length && anchor(prev[prev.length - 1])) lead.unshift(prev.pop());
        pages.push(lead);
      }
      pages[pages.length - 1].push(n);
    });
    if (pages.length > 1 && !pages[0].some(function (n) { return !n.matches(TITLE) && !anchor(n); }))
      pages.splice(0, 2, pages[0].concat(pages[1]));
    return { pages: pages, foot: foot, rule: rule };
  };
  var cur = 0, here = null;
  var base = document.title;
  // A section's address: its heading's (the H1 for the first).
  var idOf = function (s, k) {
    for (var i = 0; i < s.pages[k].length; i++) {
      var n = s.pages[k][i];
      if (/^H[1-6]$/.test(n.tagName) && n.id) return n.id;
    }
    return "";
  };
  var counter = document.createElement("p");
  counter.className = "mdb-pageno";
  counter.setAttribute("aria-live", "polite");

  // The arrows turn sections, then chapters; ours stand in where mdBook drew none, removed when unneeded.
  var arrowSel = function (dir) { return ".nav-chapters." + dir + ", .mobile-nav-chapters." + dir; };
  var ns = "http://www.w3.org/2000/svg";
  var svgIcon = function (box, d) {
    var span = document.createElement("span"), svg = document.createElementNS(ns, "svg"), path = document.createElementNS(ns, "path");
    span.className = "fa-svg";
    svg.setAttribute("viewBox", box);
    svg.setAttribute("aria-hidden", "true");
    path.setAttribute("d", d);
    svg.appendChild(path);
    span.appendChild(svg);
    return span;
  };
  var ANGLE = {
    left: "M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z",
    right: "M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"
  };
  // Pointing out, as mdBook's own: in a right-to-left book the previous arrow is on the right.
  var rtl = document.documentElement.dir === "rtl";
  var made = [];
  var ensureArrows = function () {
    [[".nav-wide-wrapper", "nav-chapters"], [".nav-wrapper", "mobile-nav-chapters"]].forEach(function (w) {
      var box = document.querySelector(w[0]);
      if (!box) return;
      ["previous", "next"].forEach(function (dir) {
        if (box.querySelector("." + w[1] + "." + dir)) return;
        var a = document.createElement("a");
        a.className = w[1] + " " + dir + " mdb-made";
        a.setAttribute("rel", dir === "next" ? "next" : "prev");
        a.setAttribute("aria-keyshortcuts", (dir === "next") !== rtl ? "Right" : "Left");
        a.setAttribute("data-mdb-href", "");
        a.appendChild(svgIcon("0 0 320 512", ANGLE[(dir === "next") !== rtl ? "right" : "left"]));
        made.push({ a: a, box: box, dir: dir });
      });
    });
  };
  var arrows = function (s) {
    var n = s.pages.length, paged = mode() === "pages";
    made.forEach(function (m) {
      var want = paged && (m.dir === "next" ? cur < n - 1 : cur > 0);
      if (want && !m.a.parentNode)
        // previous before anything, next before the clearing <div> (or last)
        m.box.insertBefore(m.a, m.dir === "previous" ? m.box.querySelector(":scope > a, :scope > div") : m.box.querySelector(":scope > div"));
      else if (!want && m.a.parentNode) m.a.parentNode.removeChild(m.a);
    });
    ["next", "previous"].forEach(function (dir) {
      document.querySelectorAll(arrowSel(dir)).forEach(function (a) {
        if (!a.hasAttribute("data-mdb-href")) a.setAttribute("data-mdb-href", a.getAttribute("href") || "");
        var orig = a.getAttribute("data-mdb-href");
        var within = dir === "next" ? cur < n - 1 : cur > 0;
        // A first section without a heading: #mdb-first, an address go() drops.
        if (paged && within) a.setAttribute("href", "#" + (idOf(s, dir === "next" ? cur + 1 : cur - 1) || "mdb-first"));
        else if (paged && orig && dir === "previous") a.setAttribute("href", orig + "#mdb-last");
        else if (orig) a.setAttribute("href", orig);
        else a.removeAttribute("href");
        a.hidden = !a.getAttribute("href");
        a.title = paged && within ? (dir === "next" ? "Next section" : "Previous section") : (dir === "next" ? "Next chapter" : "Previous chapter");
        a.setAttribute("aria-label", a.title);
      });
    });
  };

  // The sidebar's mark: in Pages, the section's, put back after each scroll.
  var mark = function () {
    var ours = null;
    document.querySelectorAll("#mdbook-sidebar a.header-in-summary").forEach(function (a) {
      var on = !!here && a.getAttribute("href") === "#" + here;
      a.classList.toggle("mdb-here", on);
      if (on) ours = a;
    });
    // mdBook opens the folds above the heading it marks: in Pages, those above ours.
    if (mode() === "pages" && !document.documentElement.classList.contains("mdb-whole")) document.querySelectorAll("#mdbook-sidebar li.header-item").forEach(function (li) {
      if (li.querySelector(":scope > span > .header-toggle")) li.classList.toggle("expanded", !!ours && li.contains(ours));
    });
  };
  // mdBook marks its own two frames after a click on its heading links: ours goes back after.
  document.addEventListener("click", function (e) {
    if (mode() === "pages" && e.target.closest && e.target.closest("#mdbook-sidebar a.header-in-summary"))
      requestAnimationFrame(function () { requestAnimationFrame(function () { requestAnimationFrame(mark); }); });
  });

  // The notes section k cites, and those the notes cite in turn.
  var notesOf = function (s, k) {
    var cited = {}, grew = true;
    var add = function (n) {
      n.querySelectorAll("sup.footnote-reference a[href^='#']").forEach(function (a) {
        var id = a.getAttribute("href").slice(1);
        if (!cited[id]) cited[id] = grew = true;
      });
    };
    s.pages[k].forEach(add);
    while (grew) {
      grew = false;
      Array.prototype.forEach.call(s.foot.children, function (li) { if (cited[li.id] === true) { cited[li.id] = 1; add(li); } });
    }
    return cited;
  };

  var show = function (k) {
    var s = split();
    var was = cur;
    cur = Math.max(0, Math.min(k, s.pages.length - 1));
    var paged = mode() === "pages";
    // A zoomed image goes with its section, as Escape would.
    if (paged && cur !== was)
      document.querySelectorAll("input.checkbox-img:checked").forEach(function (c) { c.checked = false; });
    // A section opens at its start, not scrolled sideways: go() scrolls to a place a link names.
    if (paged && cur !== was) content.scrollLeft = 0;
    s.pages.forEach(function (p, i) {
      p.forEach(function (n, j) {
        // A --- that ended a section went with the boundary.
        n.classList.toggle("mdb-off", paged && (i !== cur || (n.tagName === "HR" && j === p.length - 1)));
      });
    });
    if (s.foot) {
      // Only the notes this section cites.
      var cited = notesOf(s, cur), any = false;
      Array.prototype.forEach.call(s.foot.children, function (li) {
        var on = !paged || !!cited[li.id];
        li.classList.toggle("mdb-off", !on);
        any = any || on;
      });
      s.foot.classList.toggle("mdb-off", !any);
      if (s.rule) s.rule.classList.toggle("mdb-off", !any);
    }
    if (paged && s.pages.length > 1) {
      // Changed only when it differs, or a screen reader reads the counter twice.
      var text = "Section " + (cur + 1) + " of " + s.pages.length;
      if (counter.textContent !== text) counter.textContent = text;
      if (main.lastElementChild !== counter) main.appendChild(counter);
      var h = document.getElementById(idOf(s, cur));
      var t = h ? h.textContent.replace(/\s+/g, " ").trim() : "";
      document.title = t && cur > 0 ? t + " - " + base : base;
    } else {
      if (counter.parentNode) counter.parentNode.removeChild(counter);
      document.title = base;
    }
    arrows(s);
    // The section's mark: the heading it starts at; a chapter of one section keeps mdBook's.
    here = null;
    if (paged && s.pages.length > 1) s.pages[cur].some(function (n) { if (STARTS.test(n.tagName) && n.id) here = n.id; return !!here; });
    document.documentElement.classList.toggle("mdb-whole", s.pages.length < 2);
    mark();
  };

  // The element #x names: its id, else the first <a name="x">.
  var byId = function (id) {
    if (!id) return null;
    var el = document.getElementById(id), named = el ? [] : document.getElementsByName(id);
    for (var i = 0; !el && i < named.length; i++) if (named[i].tagName === "A") el = named[i];
    return el;
  };
  // The section an address leads to; for a note, the first section citing it.
  var pageOf = function (id) {
    var s = split();
    if (id === "mdb-last") return s.pages.length - 1;
    if (id === "mdb-first") return 0;
    var el = byId(id);
    if (!el || !main.contains(el)) return null;
    if (s.foot && s.foot.contains(el)) {
      var note = el.closest("ol.footnote-definition > li") || el;
      var cites = function (i) { return !!notesOf(s, i)[note.id]; };
      if (cites(cur)) return cur;
      for (var i = 0; i < s.pages.length; i++) if (cites(i)) return i;
      return null;
    }
    for (var j = 0; j < s.pages.length; j++)
      if (s.pages[j].some(function (n) { return n === el || n.contains(el); })) return j;
    return null;
  };
  var go = function (scroll, load) {
    // As the browser finds it: the #address as written, else decoded.
    var id = location.hash.slice(1);
    if (!byId(id)) try { id = decodeURIComponent(id); } catch (e) {}
    var k = pageOf(id), el = byId(id);
    // A search result's address is the H1-H3 above the match: on to the section with the mark.
    if (load && k !== null && mode() === "pages" && /[?&]highlight=/.test(location.search)) {
      // A match is mark.js's <mark data-markjs>, not one the author wrote.
      var s = split(), to = null, mk = null, last = true, HIT = "mark[data-markjs]";
      for (var i = k; to === null && i < s.pages.length; i++) {
        var hd = i > k ? document.getElementById(idOf(s, i)) : null;
        if (hd && +hd.tagName.charAt(1) <= 3) { last = false; break; }
        s.pages[i].some(function (n) { mk = n.matches(HIT) ? n : n.querySelector(HIT); return !!mk; });
        if (mk) to = i;
      }
      // A match only in the notes: the first section citing a marked note.
      var hits = to === null && last && s.foot ? Array.prototype.filter.call(s.foot.children, function (li) { return li.querySelector(HIT); }) : [];
      if (hits.length && !hits.some(function (li) { return notesOf(s, k)[li.id]; })) { mk = hits[0].querySelector(HIT); to = pageOf(hits[0].id); }
      // The address follows, for the arrows and Back.
      if (to !== null && to !== k) { k = to; el = mk; history.replaceState(history.state, "", addr(idOf(s, to))); }
    }
    show(k === null ? (id ? cur : 0) : k);
    if (id === "mdb-last" || id === "mdb-first") {
      // The section's own address; none for one without a heading.
      history.replaceState(history.state, "", addr(idOf(split(), cur)));
    } else if (!scroll || mode() !== "pages") return;
    // A search match to the middle of the window (no scroll-margin keeps it clear of the bar).
    var at = el && el.offsetParent !== null && el !== document.getElementById(idOf(split(), cur)) ? el : null;
    var place = function () {
      if (!at) window.scrollTo(0, 0);
      else if (at.matches("mark[data-markjs]")) at.scrollIntoView({ block: "center" });
      else at.scrollIntoView();
    };
    place();
    // Loaded with an address, place() again once the page has loaded, unless the reader moved.
    var shown = cur, nav = window.performance && performance.getEntriesByType ? performance.getEntriesByType("navigation")[0] : null;
    if (load && (!nav || nav.type === "navigate")) {
      var moved = false, stir = function () { moved = true; }, ON = ["wheel", "touchstart", "mousedown", "keydown", "hashchange"];
      ON.forEach(function (t) { window.addEventListener(t, stir, true); });
      window.addEventListener("load", function () {
        setTimeout(function () {
          ON.forEach(function (t) { window.removeEventListener(t, stir, true); });
          var r = (at || document.getElementById(idOf(split(), cur)) || main).getBoundingClientRect(), b = bar.getBoundingClientRect().bottom;
          if (!moved && cur === shown && mode() === "pages" && (at ? r.bottom <= b || r.top >= innerHeight : r.top > -2 && scrollY > 0)) place();
        }, 0);
      });
    }
  };
  window.addEventListener("hashchange", function (e) {
    // mdBook's search takes the #address off a ?search= page: put it back.
    var h = e.newURL ? new URL(e.newURL).hash : "";
    if (mode() === "pages" && h && !location.hash && /[?&]search=/.test(location.search)) history.replaceState(history.state, "", addr(h.slice(1)));
    go(true, false);
  });
  // Back and Forward between entries that differ in more than the #address fire popstate alone.
  window.addEventListener("popstate", function () {
    if (mode() === "pages" && !/[?&]search=/.test(location.search)) go(true, false);
  });

  // mdBook's search icon leaves ?search=, which reopens the bar at the next turn: drop it.
  var swrap = document.getElementById("mdbook-search-wrapper"), sbtn = document.getElementById("mdbook-search-toggle");
  var unsearch = function () {
    if (mode() !== "pages" || !swrap || !swrap.classList.contains("hidden") || !/[?&]search=/.test(location.search)) return;
    var to = idOf(split(), cur);
    history.replaceState(history.state, "", location.pathname + (to ? "#" + to : ""));
  };
  if (sbtn) sbtn.addEventListener("click", unsearch);
  // Escape: mdBook's handler drops ?search= and the #address with it; put the address back.
  document.addEventListener("keydown", function (e) {
    if (e.key !== "Escape" || mode() !== "pages" || location.hash) return;
    var to = idOf(split(), cur);
    if (to) history.replaceState(history.state, "", addr(to));
  });

  // The picker: a button beside search, opening two choices below it.
  var setMode = function (v) {
    // The choice already in use: nothing to do.
    if (v === now) return;
    var keep = null;
    if (v === "pages") {
      // Into Pages at the section at the top of the window.
      var s = split(), at = 0;
      if (window.scrollY > 10) s.pages.forEach(function (p, i) {
        if (p[0] && p[0].getBoundingClientRect().top <= 120) at = i;
      });
      cur = at;
    } else {
      keep = document.getElementById(idOf(split(), cur));
    }
    now = v;
    try { localStorage.setItem(KEY, v); } catch (e) {}
    document.documentElement.classList.toggle("mdb-pages", v === "pages");
    show(cur);
    if (v === "pages") {
      // A chapter of one section is shown whole, as in Scroll: left as it is.
      var whole = split().pages.length < 2, to = idOf(split(), cur);
      if (to && !whole) history.replaceState(history.state, "", addr(to));
      unsearch();
      if (!whole) window.scrollTo(0, 0);
    } else if (keep && cur > 0) {
      keep.scrollIntoView();
    }
    // mdBook marks the heading in view at a scroll: give it one.
    if (v === "scroll") document.dispatchEvent(new Event("scroll"));
    fill();
  };
  var left = bar.querySelector(".left-buttons");
  var btn = document.createElement("button"), menu = document.createElement("div");
  var CHOICES = [["scroll", "Scroll", "The whole chapter on one page."],
                 ["pages", "Pages", "One section at a time: the arrows turn them."]];
  var fill = function () {
    var m = mode();
    menu.textContent = "";
    CHOICES.forEach(function (o) {
      var b = document.createElement("button");
      b.type = "button";
      b.className = "mdb-layout-item";
      b.setAttribute("aria-pressed", m === o[0] ? "true" : "false");
      var t = document.createElement("span");
      t.className = "mdb-layout-name";
      t.textContent = o[1];
      var d = document.createElement("span");
      d.className = "mdb-layout-note";
      d.textContent = o[2];
      b.appendChild(t);
      b.appendChild(d);
      b.addEventListener("click", function (e) { e.stopPropagation(); setMode(o[0]); open(false); btn.focus(); });
      menu.appendChild(b);
    });
  };
  var open = function (on) {
    menu.hidden = !on;
    btn.setAttribute("aria-expanded", on ? "true" : "false");
    if (on) {
      var r = btn.getBoundingClientRect(), br = bar.getBoundingClientRect();
      menu.style.left = Math.max(8, Math.min(r.left - br.left, br.width - menu.offsetWidth - 8)) + "px";
    }
  };
  if (PICK && left) {
    btn.type = "button";
    btn.id = "mdb-layout-toggle";
    btn.className = "icon-button";
    btn.title = "Layout: scroll or pages";
    btn.setAttribute("aria-label", btn.title);
    btn.setAttribute("aria-expanded", "false");
    btn.setAttribute("aria-controls", "mdb-layout-menu");
    // Font Awesome's book-open, as mdBook draws <i class="fas fa-book-open">.
    var ns = "http://www.w3.org/2000/svg", svg = document.createElementNS(ns, "svg"), path = document.createElementNS(ns, "path");
    svg.setAttribute("viewBox", "0 0 576 512");
    svg.setAttribute("aria-hidden", "true");
    path.setAttribute("d", "M249.6 471.5c10.8 3.8 22.4-4.1 22.4-15.5V78.6c0-4.2-1.6-8.4-5-11C247.4 52 202.4 32 144 32C87.5 32 35.1 48.6 9 59.9c-5.6 2.4-9 8-9 14V454.1c0 11.9 12.8 20.2 24.1 16.5C55.6 460.1 105.5 448 144 448c33.9 0 79 14 105.6 23.5zm76.8 0C353 462 398.1 448 432 448c38.5 0 88.4 12.1 119.9 22.6c11.3 3.8 24.1-4.6 24.1-16.5V73.9c0-6.1-3.4-11.6-9-14C540.9 48.6 488.5 32 432 32c-58.4 0-103.4 20-123 35.6c-3.3 2.6-5 6.8-5 11V456c0 11.4 11.7 19.3 22.4 15.5z");
    svg.appendChild(path);
    var icon = document.createElement("span");
    icon.className = "fa-svg";
    icon.appendChild(svg);
    btn.appendChild(icon);
    menu.id = "mdb-layout-menu";
    // mdb-menu: the sidebar's swipes leave it alone.
    menu.className = "mdb-layout-menu mdb-menu";
    menu.setAttribute("role", "group");
    menu.setAttribute("aria-label", "Layout");
    menu.hidden = true;
    var search = left.querySelector("#mdbook-search-toggle");
    left.insertBefore(btn, search ? search.nextSibling : null);
    // Next to its button, so Tab goes from one to the other.
    left.insertBefore(menu, btn.nextSibling);
    fill();
    // The click goes on to the document, where the other menus close.
    btn.addEventListener("click", function (e) {
      open(menu.hidden);
      // Opened from the keyboard: into the choices, on the one in use.
      if (e.detail === 0 && !menu.hidden) (menu.querySelector('[aria-pressed="true"]') || menu.firstChild).focus();
    });
    // Left and right stay in the choices: mdBook turns the page on them.
    menu.addEventListener("keydown", function (e) {
      if (e.key === "ArrowLeft" || e.key === "ArrowRight") e.stopPropagation();
      if (e.key === "ArrowDown" || e.key === "ArrowUp") {
        e.preventDefault();
        var items = Array.prototype.slice.call(menu.children), i = items.indexOf(document.activeElement);
        items[(i + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length].focus();
      }
    });
    menu.addEventListener("focusout", function (e) {
      var to = e.relatedTarget;
      if (to && !menu.contains(to) && to !== btn) open(false);
    });
    document.addEventListener("click", function (e) { if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) open(false); });
    document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !menu.hidden) { open(false); btn.focus(); } });
    // Placed again when the window changes width (a phone turned) while open.
    window.addEventListener("resize", function () { if (!menu.hidden) open(true); });
  }

  // Back from the browser's cache: the reader may have switched layout on a later page.
  if (PICK) window.addEventListener("pageshow", function (e) {
    if (!e.persisted) return;
    // After WebKit puts the scroll position back, at the first frame after this event.
    requestAnimationFrame(function () { setTimeout(function () {
      var v = null;
      try { v = localStorage.getItem(KEY); } catch (err) {}
      setMode(v === "pages" ? "pages" : "scroll");
    }, 0); });
  });

  window.addEventListener("scroll", mark, { passive: true });
  document.documentElement.classList.toggle("mdb-pages", mode() === "pages");
  var ready = function (fn) { if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); else fn(); };
  ready(function () {
    ensureArrows();
    // mdBook's '?' popup: what the arrow keys do in Pages.
    var help = document.querySelector("#mdbook-help-popup > div");
    if (help) {
      var p = document.createElement("p"), l = document.createElement("kbd"), r = document.createElement("kbd");
      l.textContent = "←"; r.textContent = "→";
      p.appendChild(document.createTextNode(PICK ? "In Pages, " : ""));
      p.appendChild(l); p.appendChild(document.createTextNode(" and ")); p.appendChild(r);
      p.appendChild(document.createTextNode(" turn the section first"));
      help.appendChild(p);
    }
    go(!!location.hash, true);
  });
})();
EOF
    fi
    if [ "$OFFLINE" != none ]; then
        cat >> custom.js <<'EOF'

// OFFLINE: the last button on the right, after print: save in the browser ("full"; sw.js serves it), or download.
// Icons: from Font Awesome Free, the set mdBook draws its own from, with its
// notice: Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL
// OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc.
(function () {
  "use strict";
  var MODE = typeof MDB_OFFLINE === "string" ? MDB_OFFLINE : "none";
  var COPY = typeof MDB_COPY === "object" && MDB_COPY ? MDB_COPY : null;
  // The book's address: document.baseURI, since the 404 page sets <base>.
  var ROOT = new URL(typeof path_to_root === "string" && path_to_root ? path_to_root : "./", document.baseURI).href;
  // Stored copies are named by the book's path: github.io sites share an origin.
  var PREFIX = "mdb-offline " + new URL(ROOT).pathname + " ";
  var META = PREFIX + "meta";
  var CURRENT = ROOT + "__mdb-offline/current";
  var LIST = ROOT + "__mdb-offline/files-";
  var ICONS = {
    save: ["0 0 640 512", "M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128H144zm79-167l80 80c9.4 9.4 24.6 9.4 33.9 0l80-80c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-39 39V184c0-13.3-10.7-24-24-24s-24 10.7-24 24V318.1l-39-39c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9z"],
    busy: ["0 0 512 512", "M304 48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zm0 416c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM48 304c26.5 0 48-21.5 48-48s-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48zm464-48c0-26.5-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48s48-21.5 48-48zM142.9 437c18.7-18.7 18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zm0-294.2c18.7-18.7 18.7-49.1 0-67.9S93.7 56.2 75 75s-18.7 49.1 0 67.9s49.1 18.7 67.9 0zM369.1 437c18.7 18.7 49.1 18.7 67.9 0s18.7-49.1 0-67.9s-49.1-18.7-67.9 0s-18.7 49.1 0 67.9z"],
    saved: ["0 0 512 512", "M256 512c141.4 0 256-114.6 256-256S397.4 0 256 0S0 114.6 0 256S114.6 512 256 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"],
    away: ["0 0 576 512", "M482.3 192c34.2 0 93.7 29 93.7 64c0 36-59.5 64-93.7 64l-116.6 0L265.2 495.9c-5.7 10-16.3 16.1-27.8 16.1l-56.2 0c-10.6 0-18.3-10.2-15.4-20.4l49-171.6L112 320 68.8 377.6c-3 4-7.8 6.4-12.8 6.4l-42 0c-7.8 0-14-6.3-14-14c0-1.3 .2-2.6 .5-3.9L32 256 .5 145.9c-.4-1.3-.5-2.6-.5-3.9c0-7.8 6.3-14 14-14l42 0c5 0 9.8 2.4 12.8 6.4L112 192l102.9 0-49-171.6C162.9 10.2 170.6 0 181.2 0l56.2 0c11.5 0 22.1 6.2 27.8 16.1L365.7 192l116.6 0z"]
  };
  var st = { latest: null, reach: null, up: false, checked: false, saved: null, files: null, busy: null, error: "",
             online: navigator.onLine !== false };
  var LOST = "Couldn't reach the site, so the saved copy stays.";
  var button = null, panel = null, live = null;
  // Screen readers hear what a save, an update or a removal comes to.
  var say = function (text) { if (live) live.textContent = text; };

  var el = function (tag, cls, text) {
    var e = document.createElement(tag);
    if (cls) e.className = cls;
    if (text != null) e.textContent = text;
    return e;
  };
  var icon = function (name) {
    var ns = "http://www.w3.org/2000/svg";
    var s = document.createElementNS(ns, "svg"), p = document.createElementNS(ns, "path");
    s.setAttribute("viewBox", ICONS[name][0]);
    s.setAttribute("aria-hidden", "true");
    p.setAttribute("d", ICONS[name][1]);
    s.appendChild(p);
    var span = el("span", "fa-svg");
    span.appendChild(s);
    return span;
  };
  var mb = function (n) { return n >= 1e5 ? (n / 1e6).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB"; };
  var ver = function (v, built) { return "v" + v + (typeof built === "string" && built ? " · built " + built.slice(0, 10) : ""); };
  var count = function (n, one, many) { return n + " " + (n === 1 ? one : many); };
  // An error's message, less any full stop: ours follows it.
  var why = function (err) { return String(err && err.message ? err.message : err).replace(/[\s.]+$/, ""); };
  // A file of the book by its path in version.json, as the browser asks for it.
  var key = function (path) {
    return new URL("./" + path.replace(/%/g, "%25").replace(/#/g, "%23").replace(/\?/g, "%3F").replace(/\\/g, "%5C"), ROOT).href;
  };
  var json = function (v) { return new Response(JSON.stringify(v), { headers: { "Content-Type": "application/json" } }); };
  var sha256 = function (buf) {
    return crypto.subtle.digest("SHA-256", buf).then(function (d) {
      return Array.prototype.map.call(new Uint8Array(d), function (b) { return (b < 16 ? "0" : "") + b.toString(16); }).join("");
    });
  };
  var lock = function (fn) {
    return navigator.locks && navigator.locks.request ? navigator.locks.request(PREFIX + "lock", fn) : fn();
  };
  var total = function (files) {
    var n = 0;
    for (var k in files) n += files[k][0];
    return n;
  };

  // Saving needs a secure, published site: not localhost, not a file from disk, not a download.
  var standalone = function () {
    return navigator.standalone === true || !!(window.matchMedia && matchMedia("(display-mode: standalone)").matches);
  };
  var canSave = function () {
    var h = location.hostname;
    return MODE === "full" && !COPY && window.isSecureContext === true && location.protocol !== "file:" &&
      "serviceWorker" in navigator && "caches" in window && !!(window.crypto && crypto.subtle) &&
      h !== "localhost" && !/\.localhost$/.test(h) && !/^127(\.\d+){3}$/.test(h) && h !== "[::1]";
  };

  // The copy saved here, or null; strict, for a caller about to delete: errors are thrown.
  var readSaved = async function (strict) {
    try {
      if (!(await caches.has(META))) return null;
      var meta = await caches.open(META);
      var r = await meta.match(CURRENT);
      var cur = r ? await r.json() : null;
      if (!cur || !cur.cache || !(await caches.has(cur.cache))) return null;
      var f = await meta.match(LIST + cur.fingerprint);
      st.files = f ? await f.json() : null;
      return cur;
    } catch (e) {
      if (strict) throw e;
      return null;
    }
  };

  // st.up: the site answered (version.json, or a 404 for a book that's gone); onLine can't tell.
  var refresh = async function () {
    if (COPY) return;
    var v = null, gone = false;
    try {
      var r = await fetch(ROOT + "version.json", { cache: "no-cache" });
      gone = r.status === 404 || r.status === 410;
      v = r.ok ? await r.json() : null;
    } catch (e) {}
    if (v && typeof v.version === "number") st.latest = v;
    st.reach = !!(v && typeof v.version === "number");
    st.up = st.reach || gone;
    st.checked = true;
    if (st.up && st.error === LOST) st.error = "";
    render();
  };

  // What saving or updating fetches and keeps; pages counted once (no print, 404, toc, redirects, copies).
  var plan = function (L) {
    var files = L.files, old = (st.saved && st.files) || {};
    var p = { get: [], keep: [], bytes: 0, changed: 0, added: 0, removed: 0 };
    var page = function (path, entry, seen) {
      if (!/\.html$/.test(path) || /^(print|404|toc)\.html$/.test(path) || entry[2] || seen[entry[1]]) return false;
      seen[entry[1]] = true;
      return true;
    };
    var seen = {}, gone = {};
    Object.keys(files).forEach(function (path) {
      var o = old[path];
      if (o && o[1] === files[path][1]) return p.keep.push(path);
      p.get.push(path);
      p.bytes += files[path][0];
      // A page where a redirect was (output.html.redirect, since changed) is new.
      if (page(path, files[path], seen)) {
        if (o && !o[2]) p.changed++;
        else p.added++;
      }
    });
    // Gone, a redirect now, or now the first chapter's copy: removed.
    Object.keys(old).forEach(function (path) {
      var f = files[path];
      if ((!f || f[2] === 1 || (f[2] === 2 && !old[path][2])) && page(path, old[path], gone)) p.removed++;
    });
    return p;
  };
  var update = function () {
    var L = st.latest;
    return st.saved && L && L.files && L.fingerprint && L.fingerprint !== st.saved.fingerprint ? plan(L) : null;
  };

  // Four at a time; after a failure the rest are skipped, and it's thrown once all have stopped.
  var pool = function (items, n, work) {
    var i = 0, failure = null;
    var run = function () {
      if (failure || i >= items.length) return Promise.resolve();
      var item = items[i++];
      return Promise.resolve().then(function () { return work(item); })
        .then(run, function (e) { if (!failure) failure = e; });
    };
    var all = [];
    for (var k = 0; k < Math.min(n, items.length); k++) all.push(run());
    return Promise.all(all).then(function () { if (failure) throw failure; });
  };

  // Copies other than cur, and their file lists: replaced, or left by an interrupted update.
  var sweep = async function (meta, cur) {
    var names = await caches.keys();
    for (var i = 0; i < names.length; i++)
      if (names[i].indexOf(PREFIX + "copy ") === 0 && (!cur || names[i] !== cur.cache)) await caches.delete(names[i]);
    var keys = await meta.keys();
    for (var j = 0; j < keys.length; j++)
      if (keys[j].url.indexOf(LIST) === 0 && (!cur || keys[j].url !== LIST + cur.fingerprint)) await meta.delete(keys[j]);
  };

  var activated = function (reg) {
    return new Promise(function (done) {
      var w = reg.installing || reg.waiting;
      if (!w) return done();
      w.addEventListener("statechange", function () {
        if (w.state === "activated" || w.state === "redundant") done();
      });
    });
  };

  // The worker in control before the downloads: WebKit can stall a fetch passing through one.
  var ready = async function (reg) {
    // A worker starting now claims this page; one already running doesn't, so there's no wait.
    var fresh = !!(reg.installing || reg.waiting);
    await activated(reg);
    // Replaced before it started: a deploy may just have turned saving off.
    if (!reg.active) throw new Error("the site's offline service didn't start; reload the page");
    if (navigator.serviceWorker.controller || !fresh) return;
    await new Promise(function (done) {
      var timer = setTimeout(done, 5000);
      navigator.serviceWorker.addEventListener("controllerchange", function () { clearTimeout(timer); done(); }, { once: true });
    });
  };

  // Save or update: changed files fetched, the rest copied, all checked, then one switch.
  var save = async function () {
    var L = null, had = null, name = null, switched = false, reg = null;
    if (st.busy || !st.latest || !st.latest.files) return;

    var make = async function (p) {
      reg = await navigator.serviceWorker.register(ROOT + "sw.js", { scope: ROOT, updateViaCache: "none" });
      await ready(reg);
      // A name of its own: never the copy in use, whatever another tab did.
      name = PREFIX + "copy " + L.fingerprint + " " + Date.now();
      var cache = await caches.open(name);
      var old = had ? await caches.open(had.cache) : null;
      var jobs = p.keep.map(function (k) { return [k, true]; }).concat(p.get.map(function (k) { return [k, false]; }));
      await pool(jobs, 4, async function (job) {
        var path = job[0], want = L.files[path], url = key(path), res = job[1] && old ? await old.match(url) : null;
        if (!res) {
          // A minute for the answer to start, however long the file then takes.
          var stop = window.AbortController ? new AbortController() : null;
          var timer = stop ? setTimeout(function () { stop.abort(); }, 60000) : null;
          try {
            res = await fetch(url, stop ? { cache: "no-store", signal: stop.signal } : { cache: "no-store" });
          } catch (e) {
            throw new Error("couldn't fetch " + path + (stop && stop.signal.aborted ? " (no answer in a minute)" : ""));
          } finally {
            clearTimeout(timer);
          }
          if (!res.ok) throw new Error("couldn't fetch " + path + " (" + res.status + ")");
        }
        var body = await res.arrayBuffer();
        if (body.byteLength !== want[0] || (await sha256(body)) !== want[1])
          throw new Error(path + " isn't the file the site lists; the book may have just changed");
        await cache.put(url, new Response(body, { headers: { "Content-Type": res.headers.get("Content-Type") || "application/octet-stream" } }));
        st.busy.done++;
        later();
      });
      var meta = await caches.open(META);
      await meta.put(LIST + L.fingerprint, json(L.files));
      var cur = { cache: name, version: L.version, built: L.built, fingerprint: L.fingerprint };
      await meta.put(CURRENT, json(cur));
      switched = true;
      st.saved = cur;
      st.files = L.files;
      // Saved, whatever this does: what it misses goes on a later page load.
      try { await sweep(meta, cur); } catch (e) {}
    };

    st.error = "";
    st.busy = { what: "Checking…", done: 0, total: 0 };
    render();
    // The site's file list as it is now: the panel's may be a deploy behind.
    await refresh();
    L = st.latest;
    if (!st.reach || !L.files || !L.fingerprint) {
      st.busy = null;
      st.error = (st.saved ? "Couldn't update" : "Couldn't save") + ": couldn't check the book on the site.";
      say(st.error);
      render();
      return;
    }
    try {
      await lock(async function () {
        // Another tab may have saved or updated since this one looked.
        had = st.saved = await readSaved();
        if (had && had.fingerprint === L.fingerprint) return;
        var p = plan(L);
        st.busy = { what: (had ? "Updating to " : "Saving ") + "v" + L.version + "…", done: 0, total: p.keep.length + p.get.length };
        say(st.busy.what);
        render();
        // Not awaited: Firefox asks the reader, and an unanswered prompt would hold the save.
        if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(function () {});
        try {
          await make(p);
        } catch (err) {
          // Still under the lock, so no other tab saves in between.
          if (!switched) {
            if (name) { try { await caches.delete(name); } catch (e) {} }
            // No worker left behind where nothing is saved, whatever this tab thought.
            try {
              if (!(await readSaved(true)) && reg && reg.scope === ROOT) await reg.unregister();
            } catch (e) {}
          }
          throw err;
        }
      });
    } catch (err) {
      st.error = (had ? "Couldn't update: " : "Couldn't save: ") + why(err) +
        (!switched && had ? ". The saved copy is as it was." : ".");
    }
    st.busy = null;
    say(st.error || (st.saved ? "Saved on this device: v" + st.saved.version : ""));
    render();
    if (switched && had && !st.error) location.reload();
  };

  var remove = async function () {
    if (st.busy) return;
    st.busy = { what: "Removing the saved copy…", done: 0, total: 0 };
    render();
    // Only while the site answers: removed, the copy can't come back until it does.
    await refresh();
    if (!st.up) {
      st.busy = null;
      st.error = LOST;
      say(st.error);
      render();
      return;
    }
    try {
      await lock(async function () {
        var names = await caches.keys();
        for (var i = 0; i < names.length; i++)
          if (names[i].indexOf(PREFIX) === 0) await caches.delete(names[i]);
        var reg = await navigator.serviceWorker.getRegistration(ROOT);
        if (reg && reg.scope === ROOT) await reg.unregister();
      });
      st.saved = null;
      st.files = null;
      st.error = "";
    } catch (err) {
      st.error = "Couldn't remove it: " + why(err) + ".";
    }
    st.busy = null;
    say(st.error || "Removed the saved copy.");
    render();
    // Reloaded from the site, out of the removed worker's hands (WebKit can stall a save there).
    if (!st.error) location.reload();
  };

  // A button: small (Remove) unless cls makes it big (Save, Update).
  var action = function (label, fn, off, cls) {
    var b = el("button", cls || "mdb-offline-act", label);
    b.type = "button";
    b.setAttribute("data-k", label);
    b.disabled = !!off;
    b.addEventListener("click", function (e) { e.stopPropagation(); fn(); });
    return b;
  };
  // A paragraph of text and links: parts are strings or [text, address].
  var para = function (cls, parts) {
    var p = el("p", cls);
    parts.forEach(function (part) {
      if (typeof part === "string") return p.appendChild(document.createTextNode(part));
      var a = el("a", null, part[0]);
      a.href = part[1];
      a.setAttribute("data-k", part[0]);
      a.target = "_blank";
      a.rel = "noopener noreferrer";
      p.appendChild(a);
    });
    return p;
  };

  // The download: a big button, what it's for, its version, size and checksum.
  var download = function (box, c, label, what) {
    if (!c || !c.file) return;
    var a = el("a", "mdb-offline-big", label), sum = el("p", "mdb-offline-sum", "SHA-256 ");
    a.href = key(c.file);
    a.setAttribute("data-k", label);
    a.setAttribute("download", "");
    box.appendChild(a);
    box.appendChild(el("p", "mdb-offline-what", what));
    box.appendChild(el("p", "mdb-offline-note", "v" + st.latest.version + " · " + mb(c.size)));
    sum.appendChild(el("code", null, c.sha256));
    sum.appendChild(document.createTextNode(" · "));
    var s = el("a", null, "checksum file");
    s.href = key(c.file + ".sha256");
    s.setAttribute("data-k", label + " checksum");
    s.setAttribute("download", "");
    sum.appendChild(s);
    box.appendChild(sum);
  };

  // Redrawn only when something changed (a redrawn control is announced again); focus stays.
  var fill = function () {
    var inside = panel.contains(document.activeElement);
    var focus = inside ? document.activeElement.getAttribute("data-k") : null;
    var box = document.createElement("div");
    draw(box);
    if (box.innerHTML === panel.innerHTML) return;
    panel.textContent = "";
    while (box.firstChild) panel.appendChild(box.firstChild);
    if (!inside) return;
    var same = Array.prototype.find.call(panel.querySelectorAll("[data-k]"), function (n) { return n.getAttribute("data-k") === focus; });
    (same || first()).focus();
  };
  // Focus on opening: Save or Update, else the panel (or a lone download); never Remove.
  var first = function () {
    var ok = ':not([data-k="Remove saved copy"]):not(:disabled)';
    return panel.querySelector("button.mdb-offline-big" + ok) ||
      (panel.querySelector(".mdb-offline-sep") ? null : panel.querySelector("[data-k]" + ok)) || panel;
  };
  // The panel's content, into box: saving first, the download under it.
  var draw = function (box) {
    var L = st.latest, S = st.saved, u = update(), panel = box;
    panel.appendChild(el("p", "mdb-offline-title", "Read offline"));
    if (COPY) {
      panel.appendChild(el("p", null, "Downloaded copy " + ver(COPY.version, COPY.built)));
      if (typeof COPY.home === "string" && /^https?:\/\//.test(COPY.home))
        panel.appendChild(para("mdb-offline-note", ["Newer versions: ", [COPY.home, COPY.home]]));
      return;
    }
    var saving = canSave() && !!(S || (L && L.files));
    if (saving && st.busy) {
      panel.appendChild(el("p", null, st.busy.what));
      if (st.busy.total) {
        var bar = el("progress", "mdb-offline-progress");
        bar.setAttribute("aria-label", st.busy.what);
        bar.max = st.busy.total;
        bar.value = st.busy.done;
        panel.appendChild(bar);
        panel.appendChild(el("p", "mdb-offline-note mdb-offline-count", st.busy.done + " of " + st.busy.total + " files"));
      }
    } else if (saving && !S) {
      panel.appendChild(action("Save for offline", save, !st.online, "mdb-offline-big"));
      panel.appendChild(el("p", "mdb-offline-what", "Keeps the book in this browser, to read without a connection."));
      panel.appendChild(el("p", "mdb-offline-note", ver(L.version, L.built) + " · " + mb(total(L.files))));
      // Not in a Home Screen app: it has storage of its own.
      if (!standalone())
        panel.appendChild(el("p", "mdb-offline-note", "On an iPhone or iPad, add the book to your Home Screen first (Share, then Add to Home Screen) and save it from there: a book saved in Safari is deleted after 7 days of browsing without opening it."));
    } else if (saving) {
      panel.appendChild(el("p", null, "Saved on this device: " + ver(S.version, S.built)));
      if (!st.online) panel.appendChild(el("p", "mdb-offline-note", "You're offline: the book comes from this copy."));
      else if (u) {
        var behind = L.version - S.version;
        panel.appendChild(el("p", null, "Latest: " + ver(L.version, L.built) +
          (behind > 0 ? " · " + count(behind, "version", "versions") + " behind" : "")));
        panel.appendChild(el("p", "mdb-offline-note", count(u.changed, "page changed", "pages changed") + ", " +
          u.added + " new, " + u.removed + " removed · " + mb(u.bytes) + " to download"));
      } else if (st.reach === false) panel.appendChild(el("p", "mdb-offline-note", "Couldn't check for a newer version."));
      else if (L && L.files) panel.appendChild(el("p", "mdb-offline-note", "Up to date."));
      if (u && st.online) panel.appendChild(action("Update to v" + L.version, save, false, "mdb-offline-big"));
      var acts = el("div", "mdb-offline-actions");
      if (st.online && st.up) acts.appendChild(action("Remove saved copy", remove));
      if (acts.firstChild) panel.appendChild(acts);
    }
    if (st.error) panel.appendChild(el("p", "mdb-offline-error", st.error));
    // Not while busy: it would move up under the pointer, into a double-click's second click.
    var c = L && L.copies;
    if (!c || !st.online || st.busy) return;
    var d = el("div", saving ? "mdb-offline-dl mdb-offline-sep" : "mdb-offline-dl");
    download(d, c.laptop, "Download a copy", "A copy to keep on any computer, online or air-gapped. Unpack it and open index.html in the folder it makes.");
    if (d.firstChild) panel.appendChild(d);
  };

  var render = function () {
    if (!button) return;
    var L = st.latest, S = st.saved;
    var show = !!COPY || !!S || !!(st.online && L && ((canSave() && L.files) || L.copies));
    // Until the first check answers, the button keeps its place unseen.
    var wait = !show && !st.checked;
    button.hidden = !show && !wait;
    button.classList.toggle("mdb-offline-wait", wait);
    var name = st.busy ? "busy" : S && !COPY ? (st.online ? "saved" : "away") : "save";
    var label = "Read offline" + (COPY ? "" : st.busy ? ": working" : S ? (!st.online ? ": offline, saved copy" : update() ? ": update available" : ": saved") : "");
    if (button.getAttribute("data-icon") !== name) {
      button.textContent = "";
      button.appendChild(icon(name));
      button.setAttribute("data-icon", name);
    }
    button.title = label;
    button.setAttribute("aria-label", label);
    button.classList.toggle("mdb-offline-busy", !!st.busy);
    button.classList.toggle("mdb-offline-new", !st.busy && !!S && st.online && !!update());
    if (!show && !panel.hidden) toggle(false);
    if (!panel.hidden) fill();
    stale();
  };

  // A saved copy behind the site: a line before <main> (not in it: LAYOUT splits <main>).
  var line = null;
  var stale = function () {
    var L = st.latest, S = st.saved, main = document.querySelector("#mdbook-content main");
    var on = !COPY && !st.busy && st.online && st.reach === true && !!S && !!L && L.version > S.version && !!update();
    if (!on || !main) {
      if (line && line.parentNode) line.parentNode.removeChild(line);
      return;
    }
    if (!line) line = el("p", "mdb-stale");
    var text = "You're reading your saved copy, v" + S.version + ". v" + L.version + " is out: update it from the ✓ above.";
    if (line.textContent !== text) line.textContent = text;
    if (line.nextSibling !== main) main.parentNode.insertBefore(line, main);
  };

  // Progress, at most once a frame, in place: a redraw would move focus and be announced.
  var queued = false;
  var later = function () {
    if (queued) return;
    queued = true;
    requestAnimationFrame(function () {
      queued = false;
      var bar = panel.querySelector("progress"), count = panel.querySelector(".mdb-offline-count");
      if (!st.busy || !bar || !count) return render();
      bar.value = st.busy.done;
      count.textContent = st.busy.done + " of " + st.busy.total + " files";
    });
  };

  var toggle = function (open) {
    panel.hidden = !open;
    button.setAttribute("aria-expanded", open ? "true" : "false");
    if (!open) return;
    render();
    if (COPY || st.busy) return;
    // Another tab may have saved, updated or removed the copy.
    if (canSave()) readSaved().then(function (s) { if (!st.busy) { st.saved = s; render(); } });
    if (st.online) refresh();
  };

  var start = async function () {
    var bar = document.getElementById("mdbook-menu-bar");
    var right = bar && bar.querySelector(".right-buttons");
    if (!right) return;
    // Unseen, but in its place, until render() knows whether it shows.
    button = el("button", "icon-button mdb-offline-wait");
    button.id = "mdb-offline-toggle";
    button.type = "button";
    button.setAttribute("aria-haspopup", "dialog");
    button.setAttribute("aria-expanded", "false");
    button.setAttribute("aria-controls", "mdb-offline-panel");
    // Last, a space apart as mdBook's own are: the panel, at the bar's end, opens under it.
    right.append(" ", button);
    // mdb-menu: the sidebar's swipes leave it alone.
    panel = el("div", "mdb-offline-panel mdb-menu");
    panel.id = "mdb-offline-panel";
    panel.hidden = true;
    panel.setAttribute("role", "dialog");
    panel.setAttribute("aria-label", "Read offline");
    panel.tabIndex = -1;
    // Next to its button, so Tab goes from one to the other.
    right.insertBefore(panel, button.nextSibling);
    live = el("span", "mdb-offline-live");
    live.setAttribute("role", "status");
    bar.appendChild(live);
    // Left and right inside the panel stay there: mdBook turns the page on them.
    panel.addEventListener("keydown", function (e) {
      if (e.key === "ArrowLeft" || e.key === "ArrowRight") e.stopPropagation();
    });
    button.addEventListener("click", function (e) {
      toggle(panel.hidden);
      // Opened from the keyboard: into the panel, as mdBook's theme menu does.
      if (e.detail === 0 && !panel.hidden) first().focus();
    });
    // Focus moving on out of the panel closes it, as it does mdBook's theme menu.
    panel.addEventListener("focusout", function (e) {
      var to = e.relatedTarget;
      if (to && !panel.contains(to) && to !== button) toggle(false);
    });
    // A click anywhere else closes it (Safari focuses no clicked button, so focusout won't).
    document.addEventListener("click", function (e) {
      if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) toggle(false);
    });
    document.addEventListener("keydown", function (e) {
      if (e.key === "Escape" && !panel.hidden) { toggle(false); button.focus(); }
    });
    window.addEventListener("online", function () { st.online = true; refresh(); });
    window.addEventListener("offline", function () { st.online = false; render(); });
    st.checked = !!COPY || !st.online;
    // Drawn at once, unseen, so it's as wide as it will be before anything is read.
    render();
    if (canSave()) st.saved = await readSaved();
    render();
    if (!COPY && st.online) refresh();
    // A copy a cut-short save left goes, unless another tab is saving; nothing saved: all of it.
    if (canSave() && navigator.locks && navigator.locks.request) {
      navigator.locks.request(PREFIX + "lock", { ifAvailable: true }, async function (held) {
        if (!held) return;
        var cur = await readSaved(true);
        if (cur) return sweep(await caches.open(META), cur);
        var names = await caches.keys();
        for (var i = 0; i < names.length; i++)
          if (names[i].indexOf(PREFIX) === 0) await caches.delete(names[i]);
        // And the worker a first save cut short registered.
        var reg = await navigator.serviceWorker.getRegistration(ROOT);
        if (reg && reg.scope === ROOT) await reg.unregister();
      }).catch(function () {});
    }
  };
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
  else start();
})();
EOF
    fi
    say "wrote custom.js (sidebar key and swipes, external links, print return, site-meta footer, page dates, scrollable-code tabindex$([ "$THEME" != picker ] || printf ', theme menu')$([ "$LAYOUT" = scroll ] || printf ', layout (%s)' "$LAYOUT")$([ "$OFFLINE" = none ] || printf ', offline button'))"
}

# README: the title and, with a repo, the live-site link; written once, then the author's.
write_readme() {
    if [ -e README.md ]; then
        say "README.md preserved"
        return
    fi
    if [ -n "$PAGES_URL" ]; then
        printf '# %s\n\nLive site: <%s>\n' "$BOOK_TITLE" "$PAGES_URL" > README.md
        say "wrote README.md (live site: ${PAGES_URL})"
    else
        printf '# %s\n' "$BOOK_TITLE" > README.md
        say "wrote README.md (no GIT_REPO_URL, so no live-site link)"
    fi
}

# ./build at the book's root: script-owned, executable.
write_build() {
    render_build > build
    chmod +x build
    say "wrote build (./build: build the book, and stop on a broken link)"
}

# split-headings.py (SPLIT_HEADINGS, up to v55) is retired: a copy this script wrote goes.
retire_split_headings() {
    [ -f split-headings.py ] || return 0
    grep -q 'Script-owned: bootstrap-mdbook.sh rewrites it' split-headings.py || return 0
    rm -f split-headings.py
    say "removed split-headings.py: LAYOUT does its job in the browser now; commit the deletion"
}

# version.js: the deploy stamp, apart from custom.js so the pages' code doesn't change each commit.
write_version_js() {
    render_version_js > version.js
    say "wrote version.js (the deploy stamp's placeholders)"
}

# offline.py at the book's root: script-owned, run by ./build.
write_offline() {
    render_offline > offline.py
    say "wrote offline.py (OFFLINE: ${OFFLINE})"
}

write_workflow() {
    mkdir -p .github/workflows
    render_workflow > .github/workflows/deploy.yml
    say "wrote .github/workflows/deploy.yml (branch: ${DEPLOY_BRANCH})"
}

## ─── templates ───────────────────────────────────────────────────────────
# Quoted heredoc delimiter ('EOF') = literal; unquoted (EOF) = expanded.

# book.toml: ayu for THEME="dark", else light; mdb-guide.md, "Light and dark", says why.
render_book_toml() {
    cat <<EOF
[book]
title = "$(esc_dq "$BOOK_TITLE")"
authors = ["$(esc_dq "$BOOK_AUTHOR")"]
EOF
    [ -z "$BOOK_DESCRIPTION" ] || echo "description = \"$(esc_dq "$BOOK_DESCRIPTION")\""
    cat <<'EOF'
src = "src"

[output.html]
EOF
    # version.js first: custom.js reads the stamp it holds.
    echo 'additional-js = ["version.js", "custom.js"]'
    # Always loaded: it styles what custom.js adds, in every mode.
    echo 'additional-css = ["custom.css"]'
    # OFFLINE: no fingerprinted names, which would change every page at every commit.
    [ "$OFFLINE" = none ] || echo 'hash-files = false'
    # Numbers shown is mdBook's default; only hiding them needs a line.
    [ "$SIDEBAR_NUMBERS" = true ] || echo 'no-section-label = true'
    if [ "$THEME" = dark ]; then
        echo 'default-theme = "ayu"'
    else
        echo 'default-theme = "light"'
    fi
    echo 'preferred-dark-theme = "ayu"'
    if [ -n "$GIT_REPO_URL" ]; then
        cat <<EOF
site-url = "$(esc_dq "$SITE_URL")"
edit-url-template = "$(esc_dq "$GIT_REPO_URL")/edit/$(esc_dq "$DEPLOY_BRANCH")/{path}"
EOF
    fi
    # No Rust playgrounds: each makes its page ask play.rust-lang.org for its crates.
    cat <<'EOF'

[output.html.playground]
runnable = false
EOF
    # Unlisted chapters out of search, only when listed: a key matching no chapter fails the build.
    if grep -qE '\]\(\.?/?unlisted/' src/SUMMARY.md 2>/dev/null; then
        cat <<'EOF'

[output.html.search.chapter]
"unlisted" = { enable = false }
EOF
    fi
}

# About is a prefix chapter: unnumbered, above the rest.
render_summary() {
    cat <<'EOF'
# Summary

[About](./about.md)

- [Chapter 1](./chapter_1.md)
EOF
}

# printf, not a heredoc: an unquoted heredoc would expand a $ in BOOK_TITLE.
render_about() {
    printf '# %s\n\nA short description of this book — what it covers, who it'"'"'s for, and\nhow it'"'"'s organized. Replace this placeholder with your own text.\n' "$BOOK_TITLE"
}

render_chapter() {
    cat <<'EOF'
# Chapter 1
<p class="mdb-subtitle">An optional subtitle — delete this line if you don't want one</p>

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor.

## Heading 2
Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi.

### Heading 3
Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat.

#### Heading 4
Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim.

##### Heading 5
Pellentesque congue. Ut in risus volutpat libero pharetra tempor.

###### Heading 6
Cras vestibulum bibendum augue. Praesent egestas leo in pede.

---

## Paragraphs
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus luctus urna sed urna ultricies ac tempor dui sagittis.

In condimentum facilisis porta. Sed nec diam eu diam mattis viverra. Nulla fringilla.

---

## Lists

### Unordered List
- Lorem ipsum dolor sit amet
- Consectetur adipiscing elit
  - Nested item one
  - Nested item two
- Sed do eiusmod tempor

### Ordered List
1. First item
2. Second item
3. Third item

---

## Text Formatting
**Bold text**

*Italic text*

***Bold and italic***

~~Strikethrough~~

`Inline code`

---

## Blockquote
> Lorem ipsum dolor sit amet, consectetur adipiscing elit.
>
> Integer posuere erat a ante.

---

## Aside
A digression callout for a secondary point that is not the main line. Raw HTML,
because Markdown has no syntax for it; no Markdown is parsed inside the tag.

<aside>A standalone secondary thought or reference readers should know but that isn't central to the argument. Delete this block if you don't want one.</aside>

---

## Admonitions
mdBook renders these natively — no CSS of ours is involved. Five kinds:
NOTE, TIP, IMPORTANT, WARNING, CAUTION.

> [!NOTE]
> General information or additional context.

> [!WARNING]
> Critical information that highlights a potential risk.

---

## Table

| Column | Description |
| ------ | ----------- |
| One    | First row   |
| Two    | Second row  |

---

## Images
Images point at files in `src/`, which mdBook copies into the built book beside the
HTML. The path is relative to the chapter, so a chapter one folder down needs
`../demo.svg`.

![layered gray mountain silhouettes with a faint sun behind them](demo.svg)

The file above is `src/demo.svg`. Replace or delete it. Alt text is not decoration:
it is what a screen reader announces and what shows when the file is missing.

---

## Code Block
Under ten lines, so it carries no numbers:

```python
def lorem_ipsum():
    return "Dolor sit amet"
```

Ten or more, so it does:

```python
def consectetur(adipiscing, elit=None):
    """Vivamus luctus urna sed urna ultricies ac tempor."""
    sagittis = []
    for nulla in adipiscing:
        if nulla in (elit or ()):
            continue
        sagittis.append(nulla.strip().lower())
    if not sagittis:
        raise ValueError("sed nec diam mattis viverra")
    return sorted(set(sagittis))
```

---

## Wide Block
A block needing more room than the measure allows can break out of it while the prose
around it stays at its reading width. Wrap it in a `div` carrying `mdb-bleed` — a fence
cannot take a class itself — and keep the blank lines inside the wrapper: without them
the fence is read as raw HTML and renders literally.

<div class="mdb-bleed">

```python
def pellentesque(habitant, morbi=None, tristique=(), senectus="netus", fames=0):
    return {k: v for k, v in sorted(habitant.items()) if k not in (morbi or ())}
```

</div>

Delete the wrapper and the same block sits back inside the measure, scrolling sideways
instead. Nothing scopes this to code: a wide table or a diagram takes the same wrapper.

---

## Footnotes
A reference goes in the prose[^1] and its definition on its own line, anywhere in the
file. mdBook collects every definition at the foot of the chapter behind a rule, in
order of first reference, and links each one back to where you were.[^2]

Do not end a chapter with `---` if it has footnotes: mdBook draws its own rule above
the collected definitions, and you would get two.

[^1]: The definition for the first reference, written as `[^1]: text`.
[^2]: Definitions take the same markup as prose, [links](https://rust-lang.github.io/mdBook/) included.
EOF
}

# The starter chapter's picture: grey at three opacities, for either ground.
render_demo_svg() {
    cat << 'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 240">
  <circle cx="460" cy="62" r="22" fill="#888" fill-opacity="0.35"/>
  <polygon points="0,160 80,100 180,140 280,80 380,130 480,90 600,120 600,240 0,240"
           fill="#888" fill-opacity="0.3"/>
  <polygon points="0,190 100,130 220,170 340,110 460,160 600,140 600,240 0,240"
           fill="#888" fill-opacity="0.5"/>
  <polygon points="0,220 90,170 200,200 320,160 440,190 560,170 600,180 600,240 0,240"
           fill="#888" fill-opacity="0.8"/>
</svg>
EOF
}

# ./build: the build and link check, run by the author before a push and by the workflow.
render_build() {
    cat <<'EOF'
#!/usr/bin/env bash
# Builds the book into book/ and stops on a broken internal link or anchor, by file and line.
set -euo pipefail
cd "$(dirname "$0")"
for tool in mdbook lychee; do
    [ -x "bin/$tool" ] || { echo "bin/$tool is missing: run bootstrap-mdbook.sh to install it" >&2; exit 1; }
done
command -v python3 >/dev/null 2>&1 || { echo "python3 is missing: install it (on Debian or Ubuntu: sudo apt-get install python3), then run ./build again" >&2; exit 1; }
[ -f offline.py ] || { echo "offline.py is missing: run bootstrap-mdbook.sh, then commit offline.py" >&2; exit 1; }
# mdBook exits 0 on an {{#include}} it can't read, so its ERROR line stops the build.
rc=0
./bin/mdbook build 2>&1 \
    | awk '{ print; fflush() } /^(\033\[[0-9;]*m)*ERROR/ { bad = 1 } END { exit bad ? 3 : 0 }' || rc=$?
if [ "$rc" -ne 0 ]; then
    [ "$rc" -ne 3 ] || echo "stopped: mdBook reported an error, above. Fix it, then run ./build again." >&2
    exit 1
fi
# OFFLINE's files, before the link check, which follows the manifest link.
python3 -I offline.py book
# print.html repeats every chapter; 404.html's <base href> points at the live site.
rc=0
report=$(./bin/lychee --offline --include-fragments --mode plain --no-progress \
    --root-dir "$PWD/book" --exclude-path book/print.html --exclude-path book/404.html \
    'book/**/*.html' 2>&1) || rc=$?
if [ "$rc" -eq 0 ]; then
    echo "links: every internal link and anchor resolves"
    exit 0
fi

# lychee names each break by page and address; the rest maps it to the Markdown file and line.
us=$'\037'

# One line per break: page, target, anchor, reason. LC_ALL=C: %XX decodes to bytes.
breaks=$(printf '%s\n' "$report" | LC_ALL=C ROOT_P="$(pwd -P)/book/" ROOT_L="$PWD/book/" awk '
    function unhex(s,   out, i, c) {
        out = ""
        for (i = 1; i <= length(s); i++) {
            c = substr(s, i, 1)
            if (c == "%" && substr(s, i + 1, 2) ~ /^[0-9A-Fa-f][0-9A-Fa-f]$/) {
                c = tolower(substr(s, i + 1, 2)); i += 2
                c = sprintf("%c", (index(hex, substr(c, 1, 1)) - 1) * 16 + index(hex, substr(c, 2, 1)) - 1)
            }
            out = out c
        }
        return out
    }
    function in_book(p) {
        if (index(p, ENVIRON["ROOT_P"]) == 1) return substr(p, length(ENVIRON["ROOT_P"]) + 1)
        if (index(p, ENVIRON["ROOT_L"]) == 1) return substr(p, length(ENVIRON["ROOT_L"]) + 1)
        return p
    }
    BEGIN { hex = "0123456789abcdef" }
    /^\[.*\]:$/ { page = substr($0, 2, length($0) - 3); sub(/^book\//, "", page); next }
    /^\[ERROR\] / {
        url = $2; frag = ""
        if ((i = index(url, "#")) > 0) { frag = unhex(substr(url, i + 1)); url = substr(url, 1, i - 1) }
        sub(/^file:\/\//, "", url)
        i = index($0, " | ")
        print page "\037" in_book(unhex(url)) "\037" frag "\037" (i ? substr($0, i + 3) : "")
    }' | sort -u)

# Nothing to map (lychee itself failed): its report as it is, less its summary line.
if [ -z "$breaks" ]; then
    printf '%s\n' "$report" | grep -v ' Total (in ' >&2 || true
    echo "stopped: lychee failed (exit $rc); its output is above." >&2
    exit 1
fi

# SRC: the file a built page comes from; COPY=1 for the root index.html copying the first chapter.
source_of() {
    local p=$1 f
    SRC='' COPY=''
    case $p in
        toc.html) SRC=src/SUMMARY.md ;;
        index.html|*/index.html)
            for f in "src/${p%index.html}README.md" "src/${p%index.html}readme.md" "src/${p%.html}.md"; do
                [ -f "$f" ] && { SRC=$f; break; }
            done
            if [ -z "$SRC" ] && [ "$p" = index.html ]; then
                f=$(awk 'match($0, /\]\([^)]*\.md\)/) { f = substr($0, RSTART + 2, RLENGTH - 3); sub(/^\.\//, "", f); print f; exit }' src/SUMMARY.md 2>/dev/null || true)
                [ -z "$f" ] || [ ! -f "src/$f" ] || { SRC=src/$f; COPY=1; }
            fi ;;
        *.html) [ ! -f "src/${p%.html}.md" ] || SRC=src/${p%.html}.md ;;
    esac
    [ -n "$SRC" ] || [ ! -f "src/$p" ] || SRC=src/$p
    [ -n "$SRC" ] || SRC=book/$p
    return 0
}

# $1 as a regex for the ways a link can write it: each byte but a letter or digit, or its %XX.
as_written() {
    S=$1 LC_ALL=C awk 'BEGIN {
        s = ENVIRON["S"]
        for (i = 1; i < 256; i++) ord[sprintf("%c", i)] = i
        for (i = 1; i <= length(s); i++) {
            c = substr(s, i, 1)
            if (c ~ /[A-Za-z0-9]/) { out = out c; continue }
            h = sprintf("%02X", ord[c]); a = substr(h, 1, 1); b = substr(h, 2, 1)
            out = out "(" (index("\\.*$^+?(){}|[]", c) ? "\\" : "") c "|%[" a tolower(a) "][" b tolower(b) "])"
        }
        printf "%s", out
    }'
}

# Where file $1 writes the link to $6, from folder $5: "line<TAB>link"; code is skipped, as by lychee.
written() {
    LC_ALL=C NAME_RE=$2 FRAG_RE=$3 SELF=$4 BASE=$5 WANT=$6 awk '
        function unhex(s,   out, i, c) {
            out = ""
            for (i = 1; i <= length(s); i++) {
                c = substr(s, i, 1)
                if (c == "%" && substr(s, i + 1, 2) ~ /^[0-9A-Fa-f][0-9A-Fa-f]$/) {
                    c = tolower(substr(s, i + 1, 2)); i += 2
                    c = sprintf("%c", (index(hex, substr(c, 1, 1)) - 1) * 16 + index(hex, substr(c, 2, 1)) - 1)
                }
                out = out c
            }
            return out
        }
        # Address p, decoded, from folder BASE: its path in book/ ("/" first if it climbs out).
        function resolve(p, md,   n, i, k, part, seg, out) {
            p = unhex(p)
            if (substr(p, 1, 1) != "/" && ENVIRON["BASE"] != "") p = ENVIRON["BASE"] "/" p
            n = split(p, part, "/"); k = 0; out = 0
            for (i = 1; i <= n; i++) {
                if (part[i] == "" || part[i] == ".") continue
                if (part[i] == "..") { if (k) k--; else out = 1; continue }
                seg[++k] = part[i]
            }
            p = out ? "/" : ""
            for (i = 1; i <= k; i++) p = p (i > 1 ? "/" : "") seg[i]
            if (md) sub(/[.]md$/, ".html", p)
            return p
        }
        BEGIN {
            hex = "0123456789abcdef"
            q = sprintf("%c", 39)
            lead = "(\\]\\(<?|\\]:[ \t]*<?|(href|src)=[\"" q "])"
            # The folders before the name: in <> or quotes, all but the closer; bare, no space or bracket.
            n = ENVIRON["NAME_RE"]; o = ENVIRON["SELF"] != "" ? "?" : ""
            path = "(\\]\\(<|\\]:[ \t]*<)(([^<>\t]*/)?" n ")" o
            path = path "|(href|src)=\"(([^\"<>\t]*/)?" n ")" o
            path = path "|(href|src)=" q "(([^" q "<>\t]*/)?" n ")" o
            path = "(" path "|(\\]\\(|\\]:[ \t]*)(([^]()<> \t]*/)?" n ")" o ")"
            # no anchor, or an empty one (page.md#)
            path = path "#" (ENVIRON["FRAG_RE"] != "" ? ENVIRON["FRAG_RE"] : "?")
            end = "[)>\"" q " \t\r]"
            re = path "(" end "|$)"
        }
        /^[ \t]*([`][`][`]|~~~)/ { fence = !fence; next }
        fence { next }
        {
            line = $0
            gsub(/`[^`]*`/, "", line)
            while (match(line, re)) {
                s = substr(line, RSTART, RLENGTH); line = substr(line, RSTART + 1)
                match(s, "^" lead); md = substr(s, 1, RLENGTH) !~ /^src=/; s = substr(s, RLENGTH + 1)
                sub(end "$", "", s)
                t = s; sub(/#.*/, "", t)
                r = t == "" ? "" : resolve(t, md)
                if (ENVIRON["WANT"] == "" || (t == "" ? ENVIRON["SELF"] != "" : ENVIRON["WANT"] == "/" ? substr(r, 1, 1) == "/" : r == ENVIRON["WANT"])) {
                    print FNR "\t" s
                    break
                }
            }
        }' "$1"
}

# The heading anchor on page $1 sharing two thirds or more of anchor $2's words, if any.
closest() {
    { grep -ho '<h[1-6][^>]* id="[^"]*"' "book/$1" 2>/dev/null || true; } | sed 's/.* id="//; s/"$//' | sort -u |
    LC_ALL=C WANT=$2 awk '
        function words(s, out,   n, i, k, part) {
            n = split(s, part, "-"); k = 0
            for (i = 1; i <= n; i++)
                if (length(part[i]) > 2 && part[i] !~ /^[0-9]+$/ && part[i] !~ /^(and|the|for|with|from|into|that|this|what|how|why|when|are|was|not|its|you|your)$/)
                    out[++k] = part[i]
            return k
        }
        BEGIN { need = words(ENVIRON["WANT"], want) }
        need {
            k = words($0, have); hit = 0; split("", used)
            for (i = 1; i <= need; i++)
                for (j = 1; j <= k; j++)
                    if (!(j in used) && (index(have[j], want[i]) == 1 || index(want[i], have[j]) == 1)) { hit++; used[j] = 1; break }
            if (hit * 3 >= need * 2 && (hit > best || (hit == best && k - hit < fewest))) { best = hit; fewest = k - hit; pick = $0 }
        }
        END { if (pick != "") print pick }'
}

# Per break: the source file, the line the link is on, and why; sorted, each once.
printf '%s\n' "$breaks" | while IFS=$us read -r page target frag reason; do
    source_of "$page"; src=$SRC copy=$COPY
    self=
    [ -z "$frag" ] || [ "$target" != "$page" ] || self=1
    name=${target##*/}
    [ -z "$self" ] || name=${src##*/}
    case $name in
        *.html|*.md) name_re="$(as_written "${name%.*}")[.](md|html)" ;;
        *) name_re="$(as_written "$name")/?" ;;
    esac
    frag_re=$(as_written "$frag")
    case $reason in
        "Cannot find fragment"*)
            if [ -n "$self" ]; then why="no such anchor on this page"
            else source_of "$target"; why="no such anchor in $SRC"; fi
            hint=$(closest "$target" "$frag")
            [ -z "$hint" ] || why="$why; did you mean #$hint?" ;;
        "File not found"*)
            md=src/${target%.html}.md
            case $target in
                /*) why="it points outside the book" ;;
                README.html|*/README.html|readme.html|*/readme.html)
                    why="mdBook builds README.md as index.html; write index.md in place of README.md" ;;
                *.html) if [ -f "$md" ]; then why="$md isn't listed in src/SUMMARY.md, so the book has no page for it"
                        else why="there's no $md"; fi ;;
                *.md)
                    if [ -f "book/${target%.md}.html" ]; then why="its page is ${target%.md}.html: mdBook writes .html for .md in Markdown and in an <a href>, not in an HTML src"
                    elif [ -f "src/$target" ]; then why="src/$target isn't listed in src/SUMMARY.md, so the book has no page for it"
                    else why="there's no src/$target"; fi ;;
                *) why="there's no src/$target" ;;
            esac ;;
        *) why=$reason ;;
    esac
    # The folder a link in $src is read from, and the page it must lead to.
    base=${src#src/}
    [ -z "$copy" ] && [ "$base" != "$src" ] || base=$page
    case $base in */*) base=${base%/*} ;; *) base= ;; esac
    want=$target
    if [ -n "$self" ]; then
        want=$page
        [ -n "$copy" ] || [ "${src#src/}" = "$src" ] || want=${src#src/}
        want=${want%.md}; want=${want%.html}.html
    fi
    case $want in /*) want=/ ;; esac
    found=
    if [ -f "$src" ]; then found=$(written "$src" "$name_re" "$frag_re" "$self" "$base" "$want"); fi
    if [ -n "$found" ]; then
        while IFS=$'\t' read -r line link; do
            printf '%s\n' "$src$us$line$us$link$us$why$us$copy"
        done <<< "$found"
    else
        if grep -q '[{][{][[:space:]]*#[a-z_]*include' "$src" 2>/dev/null; then
            why="$why (the link isn't in this file's own text; look in the files it includes)"
        else
            why="$why (couldn't find the link in this file's text)"
        fi
        printf '%s\n' "$src$us$us$target${frag:+#$frag}$us$why$us$copy"
    fi
done | LC_ALL=C sort -t "$us" -k1,1 -k2,2n -k3,3 -k4,4 -k5,5 | LC_ALL=C awk -F "$us" '
    BEGIN { print "" }
    !seen[$1 FS $2 FS $3 FS $4]++ {
        printf "%s%s: %s\n    %s%s\n", $1, ($2 != "" ? ":" $2 : ""), $3, $4,
            ($5 != "" ? " (on index.html, the copy of this chapter at the site root)" : "")
        n++
    }
    END {
        printf "\nstopped: %d broken link%s or anchor%s, listed above. fix %s, then run ./build again.\n",
            n, (n == 1 ? "" : "s"), (n == 1 ? "" : "s"), (n == 1 ? "it" : "them")
    }' >&2
exit 1
EOF
}

# version.js: the four values the deploy workflow stamps (custom.js shows them).
render_version_js() {
    cat <<'EOF'
// Script-owned. The deploy workflow stamps these; MDB_PAGE_DATES stays a string until then.
var MDB_VERSION = "__MDB_BUILD_VERSION__";
var MDB_UPDATED = "__MDB_BUILD_DATE__";
var MDB_SHA = "__MDB_BUILD_SHA__";
var MDB_PAGE_DATES = "__MDB_PAGE_DATES__";
EOF
}

# offline.py: its five settings from the script's, then the program, verbatim.
render_offline() {
    cat <<EOF
#!/usr/bin/env python3
# Script-owned: bootstrap-mdbook.sh writes it, and a re-run rewrites it.
MODE = "$(esc_dq "$OFFLINE")"
NAME = "$(esc_dq "$BOOK_NAME")"
TITLE = "$(esc_dq "$BOOK_TITLE")"
DESCRIPTION = "$(esc_dq "$BOOK_DESCRIPTION")"
HOME = "$(esc_dq "$PAGES_URL")"
EOF
    cat <<'OFFLINEPY'

# OFFLINE's build step (./build runs it): sw.js always; unless "none", the download and version.json.

import base64
import datetime
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from urllib.parse import unquote

# A commit that changes any of these is a version (split-headings.py stays: old numbers hold).
SOURCES = ["book.toml", "src", "theme", "custom.css", "custom.js", "version.js",
           "split-headings.py", "offline.py", "build", ".github/workflows/deploy.yml"]
# The download and its checksum, inside the built book.
FOLDER = "offline"
# The deploy stamp: version.js, or its fingerprinted name when mdBook hashes files.
STAMP = re.compile(r"version(-[0-9a-f]{8})?\.js")
FONT_TYPES = {".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".otf": "font/otf"}
# Mask images: the image masthead (src/logo.svg).
MASK_TYPES = {".svg": "image/svg+xml", ".png": "image/png"}


def die(msg):
    sys.stderr.write("offline.py: %s\n" % msg)
    sys.exit(1)


def git(*args):
    """git's output, or None when git or the repository isn't there."""
    try:
        r = subprocess.run(["git"] + list(args), stdout=subprocess.PIPE,
                           stderr=subprocess.DEVNULL, universal_newlines=True)
    except OSError:
        return None
    return r.stdout if r.returncode == 0 else None


def utc(seconds):
    return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def history():
    """The versions, newest first, as [number, date, commit]."""
    rows = [line.split() for line in (git("log", "--format=%H %ct", "--", *SOURCES) or "").splitlines()]
    rows = [r for r in rows if len(r) == 2]
    return [[len(rows) - i, utc(int(t)), h[:7]] for i, (h, t) in enumerate(rows)]


def sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for block in iter(lambda: f.read(1 << 20), b""):
            h.update(block)
    return h.hexdigest()


def write(path, data):
    """A new file in place of path (in a copy, path is a hard link to the book's)."""
    if os.path.lexists(path):
        os.unlink(path)
    with open(path, "wb") as f:
        f.write(data if isinstance(data, bytes) else data.encode("utf-8", "surrogateescape"))


def read_text(path):
    """A file as text, its bytes and line ends kept through write()."""
    with open(path, encoding="utf-8", errors="surrogateescape", newline="") as f:
        return f.read()


def files_of(book, skip):
    """Every file under book, as sorted relative paths, less hidden ones and skip."""
    out = []
    for top, dirs, names in os.walk(book):
        dirs[:] = [d for d in dirs if not d.startswith(".")]
        for name in names:
            rel = os.path.relpath(os.path.join(top, name), book).replace(os.sep, "/")
            if not name.startswith(".") and rel not in skip:
                out.append(rel)
    return sorted(out)


def language(book):
    """The book's language, as mdBook's <html lang> has it."""
    with open(os.path.join(book, "index.html"), encoding="utf-8") as f:
        m = re.search(r'<html\b[^>]*\blang="([^"]*)"', f.read(4096))
    return m.group(1) if m else "en"


def stage(book, files, to):
    """A copy of the book's files under to, as hard links where it can."""
    for rel in files:
        dst = os.path.join(to, rel)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        try:
            os.link(os.path.join(book, rel), dst)
        except OSError:
            shutil.copy2(os.path.join(book, rel), dst)


def mark_copy(where, files, info, commit):
    """The copy's stamp is its version's commit, no tag, so a version's builds match; plus MDB_COPY."""
    names = [rel for rel in files if STAMP.fullmatch(rel)]
    if len(names) != 1:
        die("the built book has no version.js; book.toml's additional-js must list it")
    path = os.path.join(where, names[0])
    with open(path, encoding="utf-8") as f:
        text = f.read()
    for var, value in (("MDB_VERSION", ""), ("MDB_UPDATED", info["built"]), ("MDB_SHA", commit)):
        text = re.sub(r'^var %s = "[^"\n]*";$' % var, lambda m: "var %s = %s;" % (var, json.dumps(value)), text, flags=re.M)
    write(path, text.rstrip("\n") + "\nvar MDB_COPY = %s;\n" % json.dumps(info, sort_keys=True))


def folder_links(where, files):
    """A link to a folder (sub/) as one to its index.html, which a book opened from disk needs."""
    have = set(files)
    link = re.compile(r"""(<a(?:\s[^>]*?)?\shref=)(["'])([^"'#?]*)([#?][^"']*)?\2""", re.I)
    for rel in files:
        if not rel.endswith(".html"):
            continue
        path = os.path.join(where, rel)
        text = read_text(path)
        here = os.path.dirname(rel)

        def fix(m):
            target = m.group(3)
            if not target or re.match(r"[A-Za-z][A-Za-z0-9+.-]*:|/", target):
                return m.group(0)
            folder = os.path.normpath(os.path.join(here, unquote(target))).replace(os.sep, "/")
            index = "index.html" if folder == "." else folder + "/index.html"
            if folder == ".." or folder.startswith("../") or folder in have or index not in have:
                return m.group(0)
            return "%s%s%s%sindex.html%s%s" % (m.group(1), m.group(2), target, "" if target.endswith("/") else "/",
                                              m.group(4) or "", m.group(2))
        new = link.sub(fix, text)
        if new != text:
            write(path, new)


def embed_assets(where, files):
    """Fonts and mask images as data: URIs: from disk, browsers won't load them from files."""
    face = re.compile(r"@font-face\s*\{[^}]*\}", re.I)
    mask = re.compile(r"(?:-webkit-)?mask(?:-image)?\s*:[^;}]*", re.I)
    url = re.compile(r"""url\(\s*(['"]?)([^'")]*)\1\s*\)""", re.I)
    for rel in files:
        if not rel.endswith(".css"):
            continue
        path = os.path.abspath(os.path.join(where, rel))
        css = read_text(path)
        base = os.path.dirname(path)

        def inline(types):
            def one(m):
                target = unquote(m.group(2).strip().split("#")[0].split("?")[0])
                kind = types.get(os.path.splitext(target)[1].lower())
                if not kind or re.match(r"[A-Za-z][A-Za-z0-9+.-]*:|/", target):
                    return m.group(0)
                file = os.path.normpath(os.path.join(base, target))
                if not file.startswith(os.path.join(os.path.abspath(where), "")) or not os.path.isfile(file):
                    return m.group(0)
                with open(file, "rb") as f:
                    return 'url("data:%s;base64,%s")' % (kind, base64.b64encode(f.read()).decode("ascii"))
            return one
        new = face.sub(lambda m: url.sub(inline(FONT_TYPES), m.group(0)), css)
        new = mask.sub(lambda m: url.sub(inline(MASK_TYPES), m.group(0)), new)
        if new != css:
            write(path, new)


def readme(stem, version, built):
    home = ("%s\nOpen it when you're online and use the offline button in the menu bar."
            % HOME) if HOME else "From the site you downloaded this from."
    return """%s
Downloaded copy v%d, built %s

To read it, open index.html in this folder with any web browser. It needs no
connection.

To check the download: next to it, the site shows its SHA-256 checksum and
offers a checksum file. Save that file in the folder with the download, then:
  Linux:    sha256sum -c %s.zip.sha256
  macOS:    shasum -a 256 -c %s.zip.sha256
            (if Safari unpacked the download, it moved the original to
            the Trash: put it back first)
  Windows:  certutil -hashfile %s.zip SHA256
            (it prints the checksum: compare it with the one in
            %s.zip.sha256)
"OK" means the download is intact.

Newer versions:
%s
""" % (TITLE, version, built[:10], stem, stem, stem, stem, home)


def refresh_target(path):
    """Where a redirect page sends the reader, or None for any other file."""
    if not path.endswith(".html") or os.path.getsize(path) > 65536:
        return None
    with open(path, encoding="utf-8", errors="replace") as f:
        m = re.search(r"""<meta\s+http-equiv=["']?refresh["']?\s+content=["'][^"']*?url=([^"']*)""", f.read(), re.I)
    return m.group(1) if m else None


def first_chapter(book, files):
    """The chapter index.html copies, if any (the same but for ../ before its links)."""
    if "index.html" not in files:
        return None
    with open(os.path.join(book, "index.html"), "rb") as f:
        index = f.read()
    title = re.search(rb"<title>.*?</title>", index, re.S)
    flat = index.replace(b"../", b"")
    for rel in files:
        if not rel.endswith(".html") or rel in ("index.html", "print.html", "404.html", "toc.html"):
            continue
        with open(os.path.join(book, rel), "rb") as f:
            head = f.read(8192)
            if title and title.group(0) not in head:
                continue
            data = head + f.read()
        if data.replace(b"../", b"") == flat:
            return rel
    return None


def write_zip(where, files, dest, top):
    """The same files give the same bytes: sorted, one fixed date, fixed modes."""
    tmp = dest + ".part"
    with zipfile.ZipFile(tmp, "w") as z:
        for rel in sorted(files):
            info = zipfile.ZipInfo(top + "/" + rel, date_time=(1980, 1, 1, 0, 0, 0))
            info.create_system = 3
            info.external_attr = 0o644 << 16
            with open(os.path.join(where, rel), "rb") as f:
                z.writestr(info, f.read(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
    os.replace(tmp, dest)


def copies(book, files, version, built, commit):
    """The download in offline/, with a .sha256 file."""
    stem = "%s-v%d" % (NAME, version)
    work = tempfile.mkdtemp(prefix=".offline-", dir=book)
    try:
        laptop = os.path.join(work, "laptop")
        stage(book, files, laptop)
        mark_copy(laptop, files, {"kind": "laptop", "version": version, "built": built, "home": HOME}, commit)
        folder_links(laptop, files)
        embed_assets(laptop, files)
        write(os.path.join(laptop, "README.txt"), readme(stem, version, built))
        write_zip(laptop, sorted(set(files + ["README.txt"])), os.path.join(book, FOLDER, stem + ".zip"), stem)
    finally:
        shutil.rmtree(work, ignore_errors=True)
    name = stem + ".zip"
    path = os.path.join(book, FOLDER, name)
    digest = sha256(path)
    write(path + ".sha256", "%s  %s\n" % (digest, name))
    return {"laptop": {"file": FOLDER + "/" + name, "size": os.path.getsize(path), "sha256": digest}}


def size(n):
    return "%.1f MB" % (n / 1e6) if n >= 1e5 else "%d KB" % max(1, round(n / 1e3))


def main(argv):
    if len(argv) != 2:
        die("usage: python3 -I offline.py BOOK_DIR")
    book = argv[1].rstrip("/") or "/"
    if MODE not in ("none", "download", "full"):
        die('MODE must be "none", "download" or "full", got: %r' % MODE)
    if not os.path.isdir(book):
        die("%s is missing: build the book first" % book)
    write(os.path.join(book, "sw.js"), SW_FULL if MODE == "full" else SW_CLEANUP)
    if MODE == "none":
        return 0
    if not os.path.isfile(os.path.join(book, "index.html")):
        die("%s/index.html is missing: the download needs a book with at least one chapter" % book)
    # A version's date and commit are its own commit's, whatever came after it.
    versions = history()
    if versions:
        version, built, commit = versions[0]
    else:
        print("offline: no commit yet, so this build is v0")
        version, built, commit = 0, utc(int(datetime.datetime.now().timestamp())), ""
    if os.path.exists(os.path.join(book, FOLDER)) and not os.path.isdir(os.path.join(book, FOLDER)):
        die("%s/%s is a file, and the download needs that name for its folder: rename src/%s" % (book, FOLDER, FOLDER))
    os.makedirs(os.path.join(book, FOLDER), exist_ok=True)
    if MODE == "full":
        manifest = {"name": TITLE, "lang": language(book), "start_url": "./", "scope": "./",
                    "display": "standalone"}
        # The favicon, an SVG drawn at any size, is the app's icon.
        if os.path.isfile(os.path.join(book, "favicon.svg")):
            manifest["icons"] = [{"src": "favicon.svg", "sizes": "any", "type": "image/svg+xml"}]
        if DESCRIPTION:
            manifest["description"] = DESCRIPTION
        write(os.path.join(book, "manifest.webmanifest"),
              json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
    stem = "%s-v%d" % (NAME, version)
    skip = {"sw.js", "version.json"} | {"%s/%s%s" % (FOLDER, stem, ext) for ext in (".zip", ".zip.sha256")}
    files = files_of(book, skip)
    info = {"book": NAME, "version": version, "built": built, "commit": commit,
            "history": versions, "copies": copies(book, files, version, built, commit)}
    if MODE == "full":
        # [size, SHA-256], then 1 for a redirect page, 2 for index.html copying the first chapter.
        listed = {}
        for rel in files:
            path = os.path.join(book, rel)
            listed[rel] = [os.path.getsize(path), sha256(path)] + ([1] if refresh_target(path) is not None else [])
        if first_chapter(book, files) and len(listed["index.html"]) == 2:
            listed["index.html"].append(2)
        # The stamp changes with every commit; the book doesn't.
        info["fingerprint"] = hashlib.sha256("".join(
            "%s %s\n" % (rel, listed[rel][1]) for rel in files if not STAMP.fullmatch(rel)
        ).encode("utf-8")).hexdigest()
        info["files"] = listed
    write(os.path.join(book, "version.json"),
          json.dumps(info, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
    c = info["copies"]
    print("offline: v%d; air-gapped laptop's copy %s%s"
          % (version, size(c["laptop"]["size"]),
             "; %d files to save, %s" % (len(files), size(sum(v[0] for v in info["files"].values())))
             if MODE == "full" else ""))
    return 0


# Outside "full": deletes a copy a reader saved before, then itself, and reloads open pages.
SW_CLEANUP = r"""// Written by offline.py (OFFLINE "none" or "download"): deletes the copy a
// reader saved while the book was "full", unregisters, and reloads open pages.
self.addEventListener("install", function () { self.skipWaiting(); });
self.addEventListener("activate", function (e) {
  e.waitUntil((async function () {
    var prefix = "mdb-offline " + new URL("./", self.location).pathname + " ";
    var names = await caches.keys();
    for (var i = 0; i < names.length; i++)
      if (names[i].indexOf(prefix) === 0) await caches.delete(names[i]);
    await self.registration.unregister();
    var open = await self.clients.matchAll({ type: "window" });
    for (var j = 0; j < open.length; j++) open[j].navigate(open[j].url).catch(function () {});
  })());
});
"""

# OFFLINE "full": serves every page of the book from the saved copy, online or offline.
SW_FULL = r"""// Written by offline.py (OFFLINE "full"). Registered when a reader saves the
// book; serves the saved copy (custom.js saves and updates it).
"use strict";
var ROOT = new URL("./", self.location).href;
// Copies are named by the book's path: github.io sites share one origin.
var PREFIX = "mdb-offline " + new URL(ROOT).pathname + " ";
var META = PREFIX + "meta";
var CURRENT = ROOT + "__mdb-offline/current";

self.addEventListener("install", function () { self.skipWaiting(); });
self.addEventListener("activate", function (e) { e.waitUntil(self.clients.claim()); });

// The saved copy, or null; read on every request: an update switches it with one write.
async function current() {
  if (!(await caches.has(META))) return null;
  var r = await (await caches.open(META)).match(CURRENT);
  if (!r) return null;
  var cur = null;
  try { cur = await r.json(); } catch (e) { return null; }
  return cur && cur.cache && (await caches.has(cur.cache)) ? cur : null;
}

// The copy's file for an address: the file, a folder's index.html, or a page without .html.
async function lookup(cache, address) {
  var u = new URL(address);
  u.search = "";
  u.hash = "";
  if (u.href.indexOf(ROOT) !== 0) return null;
  var rel = u.href.slice(ROOT.length);
  var names = rel === "" || rel.slice(-1) === "/" ? [rel + "index.html"]
    : /\.[^\/]*$/.test(rel) ? [rel] : [rel, rel + ".html", rel + "/index.html"];
  for (var i = 0; i < names.length; i++) {
    var hit = await cache.match(ROOT + names[i]);
    if (hit) return i === 2 ? Response.redirect(u.href + "/", 301) : hit;
  }
  return null;
}

async function answer(e) {
  var req = e.request;
  var cur = await current();
  if (!cur) return fetch(req);
  var cache = await caches.open(cur.cache);
  if (req.mode === "navigate") {
    var page = await lookup(cache, req.url);
    if (page) return page;
    // Not a page of the copy: the network, else the copy's 404 page.
    try {
      return await fetch(req);
    } catch (err) {
      var missing = await cache.match(ROOT + "404.html");
      if (!missing) throw err;
      return new Response(await missing.blob(), { status: 404, statusText: "Not Found", headers: missing.headers });
    }
  }
  // A page that came from the copy gets everything else from it too.
  var client = e.clientId ? await self.clients.get(e.clientId) : null;
  if (client && (await lookup(cache, client.url))) {
    var hit = await lookup(cache, req.url);
    if (hit) return part(req, hit);
  }
  return fetch(req);
}

// The byte range a player asks for (iOS plays media by ranges); anything else, the whole file.
async function part(req, res) {
  var m = /^bytes=(\d*)-(\d*)$/.exec((req.headers.get("Range") || "").trim());
  if (res.status !== 200 || !m || (m[1] === "" && m[2] === "") || (m[1] !== "" && m[2] !== "" && +m[2] < +m[1])) return res;
  var body = await res.blob(), n = body.size;
  var a = m[1] === "" ? Math.max(0, n - +m[2]) : +m[1];
  var b = m[1] === "" || m[2] === "" ? n - 1 : Math.min(+m[2], n - 1);
  var h = new Headers(res.headers);
  if (a > b) {
    h.set("Content-Range", "bytes */" + n);
    return new Response(null, { status: 416, statusText: "Range Not Satisfiable", headers: h });
  }
  h.set("Content-Range", "bytes " + a + "-" + b + "/" + n);
  h.set("Content-Length", String(b - a + 1));
  return new Response(body.slice(a, b + 1), { status: 206, statusText: "Partial Content", headers: h });
}

self.addEventListener("fetch", function (e) {
  var req = e.request;
  if (req.method !== "GET" || req.url.indexOf(ROOT) !== 0) return;
  // Saving and updating fetch with no-store, from the network.
  if (req.cache === "no-store") return;
  var rel = req.url.slice(ROOT.length).split(/[?#]/)[0];
  if (rel === "sw.js" || rel === "version.json") return;
  // If the saved copy can't be read at all, the site.
  e.respondWith(answer(e).catch(function () { return fetch(req); }));
});
"""

if __name__ == "__main__":
    sys.exit(main(sys.argv))
OFFLINEPY
}

# The deploy workflow, pinned as lookup_releases found it; only the deploy job can publish.
render_workflow() {
    local numbering_step=""
    if [ "$HEADING_NUMBERS" = true ]; then
        numbering_step="

      - name: Install mdbook-numbering ${NUMBERING_VERSION}
        run: cargo install --locked --version ${NUMBERING_VERSION} mdbook-numbering"
    fi

    cat <<EOF
name: Deploy mdBook to GitHub Pages

on:
  push:
    branches: [${DEPLOY_BRANCH}]
    tags: ['v*']
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: ${CHECKOUT_USES}
        with:
          # Full history and tags: the stamp reads the tag.
          fetch-depth: 0
          # The build runs the repo's code; it has no use for a git token.
          persist-credentials: false

      - name: Install mdBook ${MDBOOK_VERSION}
        run: |
          set -euo pipefail
          base="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
          curl --fail -sSL "\$base" -o mdbook.tar.gz
          # The digest the bootstrap checked: a tag names a file, not its bytes.
          echo "${MDBOOK_SHA256}  mdbook.tar.gz" | sha256sum -c -
          mkdir -p bin
          tar -xz -f mdbook.tar.gz --directory=bin mdbook${numbering_step}

      - name: Install lychee ${LYCHEE_VERSION}
        run: |
          set -euo pipefail
          base="https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/lychee-x86_64-unknown-linux-gnu.tar.gz"
          curl --fail -sSL "\$base" -o lychee.tar.gz
          # Pinned by version and sha256, as mdBook is.
          echo "${LYCHEE_SHA256}  lychee.tar.gz" | sha256sum -c -
          tar -xz -f lychee.tar.gz --strip-components=1 \\
            --directory=bin lychee-x86_64-unknown-linux-gnu/lychee

      - name: Stamp build metadata
        run: |
          set -euo pipefail
          # Version: the tag on this commit, or the nearest one plus a count (v1.2+5).
          version=\$(git describe --tags 2>/dev/null | sed -E 's/-([0-9]+)-g[0-9a-f]+\$/+\\1/' || true)
          version=\${version//[^A-Za-z0-9._+-]/}
          # Each chapter source's last commit time, in UTC; paths outside a safe charset skipped.
          dates="{" sep=""
          while IFS= read -r -d '' f; do
            case "\$f" in *.md) ;; *) continue ;; esac
            case "\$f" in *[!A-Za-z0-9._/' '-]*)
              echo "page dates: skipping '\$f' (character outside the safe set)"; continue ;;
            esac
            d=\$(TZ=UTC git log -1 --date=format-local:%Y-%m-%dT%H:%M:%SZ --format=%ad -- "\$f")
            [ -n "\$d" ] || continue
            dates="\$dates\$sep\\"\$f\\":\\"\$d\\""
            sep=","
          done < <(git ls-files -z -- src)
          dates="\$dates}"
          # The commit's time, not the clock's: two builds of a commit are the same.
          built=\$(TZ=UTC git log -1 --date=format-local:%Y-%m-%dT%H:%M:%SZ --format=%cd)
          sed -i \\
            -e "s|__MDB_BUILD_VERSION__|\${version}|" \\
            -e "s|__MDB_BUILD_DATE__|\${built}|" \\
            -e "s|__MDB_BUILD_SHA__|\${GITHUB_SHA::7}|" \\
            -e "s|\\"__MDB_PAGE_DATES__\\"|\${dates}|" \\
            version.js

      - name: Build and check links
        # The ./build the author runs before a push: a broken link stops the deploy.
        run: bash ./build

      - name: Upload artifact
        uses: ${UPLOAD_USES}
        with:
          path: ./book

  deploy:
    needs: build
    # Only this job publishes, so only this job gets the two permissions.
    permissions:
      pages: write
      id-token: write
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: \${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: ${DEPLOY_USES}
EOF
}

## ─── entry point ─────────────────────────────────────────────────────────
# Guarded so the file can be sourced to test a render_* function.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

Minimal Zola Blog on GitHub Pages

The zola-blog-setup.sh script makes a small, fast blog on your computer and sets it up so GitHub publishes it for free. You write each post as a plain text file. A program called Zola turns those files into web pages, and GitHub puts the pages online each time you send in your changes.

What you get

  • A home page that lists your posts, numbered, in the order you choose, or newest first (see “Ordering posts”). Also an About page, tags, an RSS feed, and search.
  • A plain, readable design. It is dark by default, and you can add a light version for readers whose computer is set to light mode.
  • Nothing loaded from other websites: no tracking, no adverts, and no fonts or scripts from other companies. Every page carries a security policy that makes the reader’s browser enforce this.
  • One small script, for the search box. Search is on by default, so by default the site does run JavaScript: this one file, loaded from your own site and nowhere else. Turn search off (ENABLE_SEARCH=false) and the blog’s pages run no JavaScript at all. The one exception is the address /attachments/, which Zola publishes as a redirect to the home page: one line of Zola’s own script, on a page without the security policy.
  • Automatic publishing. Each time you push your changes to GitHub, it rebuilds the site and puts it online, usually within a couple of minutes.
  • Optional email updates for readers, through a newsletter service that sends them each new post from your feed (see “Email subscription”).

The preview on your own computer adds a script of its own, which reloads the page when you save a file. It is part of Zola’s preview and is never published. The preview also shows your drafts, marked “(draft)”, and they aren’t published either.

It costs nothing, unless you want your own domain name (roughly 10 to 20 US dollars a year; see “Custom domain”). GitHub’s address, hosting and HTTPS are free at the size a personal blog runs at.

The script runs on Linux, macOS, and Windows (in Git Bash). It uses Zola 0.23, any 0.23.x release, and installs one for you if you don’t have it. It won’t install a newer Zola than that, because the blog’s templates are written for 0.23 and a later version may not build them. If the Zola already on your computer isn’t a 0.23 release, the script installs one into ~/.local/bin and says so; your other Zola sites may then build with that one too.

Words used in this guide

  • Zola: the program that turns your text files into web pages. The script installs it.
  • Static site: a website made of finished files. No program runs on the server, so there’s little that can go wrong or be attacked, and it’s cheap to host.
  • GitHub: the website that stores your blog’s files. A repository (repo) is one project’s folder there.
  • GitHub Pages: GitHub’s free web hosting. GitHub Actions is GitHub’s build service, and a workflow is the file that tells it what to do. Yours is .github/workflows/deploy.yml.
  • git: the program on your computer that keeps track of changes and sends them to GitHub. A commit is a saved snapshot of your changes. To push is to send your commits to GitHub.
  • Terminal: the window where you type commands.
  • Markdown: the simple text format you write posts in. **bold** becomes bold, and a line starting ## becomes a heading.
  • Front matter: the block of settings at the top of each post, between two lines of +++.
  • Attachments: the folder content/attachments/, where pictures, GIFs and videos go, for any post or page.
  • Draft: a post with draft = true in its front matter. The preview shows it; the published site doesn’t.
  • Post: a page in the list on the home page. A hidden page, such as the About page, is published but kept out of every list; see “Pages that aren’t posts”. A section is a folder with an _index.md file in it. It’s listed like a post, and its page lists the pages inside it, its child pages; see “Child pages”.
  • Preview: a private copy of the site on your computer, at http://127.0.0.1:1111, that updates each time you save a file. Only you can see it.
  • Build: making the finished site files, which go into a folder called public/.
  • The configuration block: the list of settings at the top of the script.
  • Re-run: running the script again after changing a setting. That’s how every change to a setting is applied.
  • Script-owned files: files the script writes again on every run, so changes you make to them directly don’t last. Everything in content/ and most of static/ is yours instead, and the script never touches it after the first run. So is README.md, once you edit it. See “How changes work”.
  • render_ functions: the parts of the script that write each generated file. render_base_html, for example, writes templates/base.html. “How changes work” says how to find one.
  • Feed (RSS or Atom): a list of your posts, in a format that feed reader apps and email services check for new posts.
  • Tags: labels you give posts. Each tag gets a page listing the posts that carry it.
  • Masthead: the strip at the top of every page, with the blog’s name on the left and the menu on the right.
  • Favicon: the small icon in the browser tab.
  • Base URL: the address the published site lives at, such as https://John.github.io/notes. The script works it out from your repository’s address.
  • Checksum (sha256): a fingerprint of a file. Change one byte of the file and the fingerprint changes.
  • Security policy (CSP, Content-Security-Policy): a list of rules in each page that tells the reader’s browser what the page may load and run.

Your first blog

Before you start

You need:

  • A computer running Linux, macOS, or Windows with Git Bash (it comes with Git for Windows).
  • A terminal. On Linux it’s usually called Terminal (in XFCE, Terminal Emulator). On a Mac, it’s Terminal, in Applications, then Utilities. On Windows, use Git Bash.
  • git, and curl or wget. To check for git, run git --version. It should print something like git version 2.47.2. If it prints command not found, install it; on Debian, Devuan or Ubuntu, sudo apt install git curl installs both.
  • A plain-text editor, for changing the script’s settings: Mousepad, gedit, Kate, nano, VS Code, or similar. Don’t use a word processor. It may change straight quotes into curly ones, which breaks the script.
  • A GitHub account, if you want the blog online. It’s free; sign up at github.com.

You don’t need to install Zola first, and the script never needs administrator rights. It checks for the other small tools it uses and tells you by name if any are missing.

Step 1: tell git who you are

git stamps every commit with a name and an email address, and on a public blog anyone can read both in the history, permanently. So pick them before your first run. The first run saves your first commit for you, but only if git already knows your name and email.

You don’t have to publish your real email address. GitHub gives you a private one: on github.com, click your profile picture (top right), then Settings, then Emails. Tick “Keep my email addresses private”, and copy the address shown there, which ends in @users.noreply.github.com.

Then, in a terminal:

git config --global user.name "Your Name"
git config --global user.email "Your Email"

Put your own name and the address you copied in place of Your Name and Your Email, and keep the quotes. Neither command prints anything. To check, run git config --global user.email; it prints the address back.

--global sets this for every git project on your computer. To use a different name or address for the blog only, run the same two commands inside the blog folder, without --global.

Step 2: save the script and read it

Open the page for zola-blog-setup.sh and copy the whole script (on a code block, the copy button at its top right copies all of it). Paste it into a new file in your text editor, and save that file as zola-blog-setup.sh in your Downloads folder. Any other folder works too, but this guide assumes Downloads. Where you keep the script doesn’t decide where the blog goes: the settings do, and by default it’s a folder called myblog on your Desktop.

Read the script before you run it, or have someone you trust read it: it downloads a program and connects your blog to GitHub. If you don’t read shell scripts, paste it into an AI model you trust and ask what it does. About half of the script is the text of the files it writes. It downloads only from GitHub, installs nothing outside your home folder, and never asks for your password.

Step 3: change the settings

Open the script in your text editor (on XFCE, for example, mousepad zola-blog-setup.sh) and search for user configuration. The lines below it are the configuration block, one setting per line, like these:

readonly SITE_TITLE="Myblog"
readonly SITE_DESCRIPTION="one-line description"   # "" for none
readonly LIGHT_THEME=false        # true: light for readers whose OS asks for it

The name in capitals is the setting; what follows the = is its value. Everything after a #, on a line of its own or at the end of a setting’s line, is a note, and the script ignores it. The block ends at the line derived from the block above (do not edit).

Rules for changing a value:

  • Change the setting’s own line; don’t add a second line for the same setting. A second line stops the script with an error such as line 82: GIT_REPO_URL: readonly variable.
  • Change only what comes after the =. Leave everything before it alone, and don’t put spaces around the =.
  • Text goes inside double quotes: readonly SITE_TITLE="John's Notes". An apostrophe inside double quotes is fine.
  • Inside the quotes, put a backslash before any $, " or `: readonly SITE_DESCRIPTION="Notes on \$5 gadgets", or readonly FOOTER_TEXT="Motto: \"ora et labora\"". Without it the script stops with an error such as line 89: $5: unbound variable, or, for a " or `, line 117: FOOTER_TEXT isn't quoted as the script needs. The one exception is $HOME in PROJECT_DIR’s default, which is meant to be read as your home folder.
  • true and false have no quotes. If one is wrong, the script stops with a message such as SHOW_TOC must be true or false (unquoted), got: 'yes'.
  • An empty value is two quotes with nothing between them: readonly SITE_DESCRIPTION="".

For a first try you can change nothing at all. You’ll get a blog called “Myblog” in ~/Desktop/myblog, with two example posts, and nothing is published until you set up GitHub.

For your real blog, set these now (each is explained under “Settings, one by one”):

  • SITE_TITLE: the blog’s name.
  • SITE_DESCRIPTION: one line about the blog, shown under its name on the home page.
  • SITE_AUTHOR: your name, as it appears in the feed.
  • BLOG_NAME: the name of the blog’s folder on your computer. Letters, numbers, dashes and underscores; no spaces.
  • PROJECT_DIR: the folder the blog’s folder goes in. The default is your Desktop.

If you already have an empty GitHub repository for the blog, you can also set GIT_REPO_URL now (see “Putting it online”). It’s fine to add it later.

Save the script when you’ve made your changes.

Step 4: run it

In the terminal, go to the folder where you saved the script and run it. If you saved it in Downloads:

cd ~/Downloads
bash zola-blog-setup.sh

The first run downloads Zola, which takes a moment. You should see something like this. Your folder names will be different, and the version number and checksum may be too:

zola not found; installing 0.23.6...
downloading zola v0.23.6...
sha256 of the downloaded archive: 8f5132b3522412d04e395e0b25f6d68613ad272a873e54a2b3ebf664873024a4
installing to /home/you/.local/bin...
installed: zola 0.23.6

creating /home/you/Desktop/myblog ...
wrote the script-owned files (config.toml, templates/, serve, build, new, attach, README.md, .gitignore)
wrote content/ (yours from here; a re-run will not touch it)
blog created in /home/you/Desktop/myblog

initialised git repo on branch: main
made initial commit

starting the live preview (your browser will open). Ctrl+C to stop.
to start it again later, from anywhere: /home/you/Desktop/myblog/serve

If you already have Zola 0.23, the lines about installing it are replaced by one line, zola 0.23.x present (inside the supported band >= 0.23, < 0.24); using it.

Then Zola starts the preview:

Building site...
-> Creating 3 pages (0 orphan) and 1 sections
Done in 64ms.

Listening for changes in /home/you/Desktop/myblog/{config.toml,content,static,templates}
Press Ctrl+C to stop

Web server is available at http://127.0.0.1:1111 (bound to 127.0.0.1:1111)

Your browser opens the preview at http://127.0.0.1:1111. (If another preview is already running, the new one uses a different address, such as http://127.0.0.1:1024, and prints it.) Only you can see it, and it updates by itself whenever you save a file in the blog folder. While the preview runs, the terminal stays busy: press Ctrl+C in it to stop the preview, or open a second terminal window for other commands.

Some lines look alarming but are normal:

  • sha256 of the downloaded archive is the fingerprint of the Zola download, printed for your records. See “What the checksum proves”.
  • If ~/.local/bin, where Zola goes, isn’t yet among the folders your terminal looks in for programs (your PATH), a note after installed: zola 0.23.6 says so. The preview this run starts works anyway; the note is about the next terminal you open. Log out and back in, and see “Zola not found after installing” if that isn’t enough.
  • If git didn’t know your name and email yet, you see (git user.name/user.email not configured - initial commit skipped) instead of made initial commit. The blog is fine. Do step 1 above, then make the first commit yourself. In a second terminal window (the preview is using this one), go into the blog folder, the one named after blog created in, with a command such as cd ~/Desktop/myblog. Then run git add -A, which prints nothing, and git commit -m "First commit", which prints a line starting [main (root-commit).
  • If you set GIT_REPO_URL, you also see three lines about pinning Zola into the workflow, which means recording the exact Zola version GitHub will use, and added remote 'origin': git@github.com:you/yourrepo.git. That’s the link to GitHub being set up.
  • If you set GIT_REPO_URL and some settings still have the values the script came with, a line starting still the examples the script came with names them. It’s a reminder, not an error; see “Before you publish”.

If a first run stops with an error instead, it deletes the folder it was creating, so fix what the message names and run it again. Troubleshooting lists the messages.

To start the preview again later, from any folder:

~/Desktop/myblog/serve

If that says zola: command not found, see “Zola not found after installing” in Troubleshooting.

Step 5: find your way around

The blog folder now holds:

  • content/: your writing. Each post is a .md file here, and content/attachments/ holds the pictures, GIFs and videos for any post or page. content/about.md is the About page, and content/_index.md holds any text you want at the top of the home page. The two example posts, hello-world.md and second-post.md, show how things look, and hello-world.md doubles as a Markdown cheat sheet; its picture is content/attachments/demo.svg.
  • static/: files published exactly as they are, such as fonts and the About page’s placeholder picture.
  • config.toml and templates/: generated by the script from its settings. Don’t edit these directly; see “How changes work”.
  • serve and build: shortcuts. From inside the blog folder, ./serve starts the preview and ./build makes the finished site in public/, the same way GitHub will, so a problem that would stop GitHub’s build stops it here first, with a message.
  • new and attach: helpers for writing. ./new starts a post with its front matter filled in (see “A new post”), and ./attach copies a picture or video into content/attachments/ with its hidden data removed (see “Pictures, GIFs and video”).
  • README.md: a short note for the repository’s page on GitHub, written by the script until you edit it; after that it’s yours (see “The hand-edit check”).
  • .gitignore, .zola-blog-setup.manifest and, once you set up publishing, .github/workflows/deploy.yml. .gitignore tells git what not to commit, such as the built site in public/; the manifest is the script’s record of the files it wrote (see “The hand-edit check”); and deploy.yml tells GitHub how to build and publish the site. Names starting with a dot are hidden in most file managers; Ctrl+H shows them.

From here on, this guide writes the blog folder as ~/Desktop/myblog and the script as ~/Downloads/zola-blog-setup.sh, the defaults. If yours are somewhere else, use your own paths in their place.

Replace the example posts with your own when you’re ready. Delete both together, or hello-world.md first: it links to second-post.md, so deleting only second-post.md stops the build with a broken-link error. Delete content/attachments/demo.svg along with them.

Putting it online

Before you publish

Whatever is in the blog when you push goes online, so replace the placeholders first:

  • SITE_TITLE (“Myblog”), SITE_DESCRIPTION (“one-line description”) and SITE_AUTHOR (“your name”).
  • FOOTER_LINKS. It starts with example addresses for X, Nostr and GitHub that lead nowhere. Put in your own, delete the ones you don’t want, or set it to "" for none; see “Footer text and links”.
  • The About page, content/about.md, which starts with placeholder text and the address you@yourdomain.com.
  • The two example posts, unless you want them published. “Step 5: find your way around” says which to delete first.

While GIT_REPO_URL is set, each run lists any of the settings above, and the About page, that still have their example values, in a line starting still the examples the script came with.

On a free GitHub account the repository has to be public, and everything you commit is in it for anyone to read, including the history of every change, posts marked as drafts, and the original files of any photos. Don’t commit anything you wouldn’t publish.

Step 1: make an SSH key (once per computer)

When you push, GitHub has to know it’s you. The script sets the blog up to prove that with an SSH key: a pair of files on your computer, a private one that never leaves it and a public one you give to GitHub. If you’ve pushed to GitHub from this computer before, you probably have one already. To check, run ls ~/.ssh/id_ed25519.pub: it prints the file’s name if the key exists, or No such file or directory if it doesn’t. (A key with another name, such as id_rsa.pub, works too; use its name in the steps below.)

To make one:

ssh-keygen -t ed25519 -C "laptop"

The text after -C is only a label, to help you recognise the key later. The command asks three things. Press Enter at the first, to save the key in the usual place (~/.ssh/id_ed25519). Then it asks for a passphrase, twice. A passphrase protects the key if someone copies your files; type one, or press Enter both times for none. Nothing appears on screen while you type it, which is normal. It finishes by printing the key’s fingerprint and a small box of symbols called randomart. You don’t need to keep either.

Show the public half:

cat ~/.ssh/id_ed25519.pub

It prints one long line starting with ssh-ed25519 and ending with your label. Copy the whole line. On github.com, go to Settings, then SSH and GPG keys, then New SSH key (or open github.com/settings/keys). Paste the line into the Key box, give it a title such as “laptop”, and click Add SSH key.

Check that it works:

ssh -T git@github.com

The first time, ssh asks whether to trust the server, with something like this:

The authenticity of host 'github.com (IP ADDRESS)' can't be established.
ED25519 key fingerprint is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU.
Are you sure you want to continue connecting (yes/no/[fingerprint])?

Check that the fingerprint matches the one above, which is the one GitHub publishes, then type yes and press Enter. (If ssh shows a different type of key, compare it with GitHub’s list: search the web for “GitHub’s SSH key fingerprints”, and use the page on docs.github.com.) You should see:

Hi yourname! You've successfully authenticated, but GitHub does not provide shell access.

That means it worked, even though it sounds like a refusal. If you see Permission denied (publickey) instead, see Troubleshooting.

Step 2: make an empty repository on GitHub

On github.com, click the + at the top right, then New repository.

  • The repository’s name becomes part of your blog’s address. A repository called notes on the account John is published at https://John.github.io/notes/. If you call it exactly yourname.github.io, with your username in lower case (GitHub requires lower case here, even for a username with capitals), the blog is published at https://yourname.github.io/ with nothing after it.
  • Choose Public. GitHub Pages is free for public repositories; private ones need a paid plan.
  • Leave the options to add a README, a .gitignore and a licence turned off. The repository has to be empty: your computer already has the first commit, and a file added on GitHub would give the repository a different first commit that clashes with it.

Click Create repository. GitHub then shows some setup commands; you don’t need them, because the script does that part.

Step 3: connect the blog to the repository

Skip this step if you set GIT_REPO_URL before your first run.

Copy the repository’s address from your browser’s address bar while you’re on its main page. It looks like https://github.com/John/notes: https://github.com/, your username, the repository’s name, and nothing after that. An address copied from further inside the repository, such as one ending in /tree/main, stops the script with a message asking for the main page’s address.

In the configuration block, find the line that starts readonly GIT_REPO_URL="" and put the address between its quotes, so that it reads:

readonly GIT_REPO_URL="https://github.com/John/notes"          # https://github.com/user/repo; "" = local only

Save the script, then run it again from the folder it’s in:

cd ~/Downloads
START_PREVIEW=false bash zola-blog-setup.sh

START_PREVIEW=false in front of the command skips the preview, for this run only. You should see:

found an existing blog in /home/you/Desktop/myblog
updating the script-owned files; content/ is left untouched.

zola 0.23.6 present (inside the supported band >= 0.23, < 0.24); using it.
pinning zola 0.23.6 into the workflow (same build as this machine)
hashing the linux zola 0.23.6 build to pin it into the workflow...
pinned the zola builder digest into the workflow
wrote the script-owned files (config.toml, templates/, serve, build, new, attach, README.md, .gitignore)
added remote 'origin': git@github.com:John/notes.git

updated.

skipping the live preview. start it any time, from anywhere:
    /home/you/Desktop/myblog/serve

If some settings still have the values the script came with, you also see a line starting still the examples the script came with that names them; see “Before you publish”.

This wrote .github/workflows/deploy.yml, the instructions GitHub follows to build and publish the site. It set the site’s address to https://John.github.io/notes, and told git where to push (a link called origin). The “hashing” line is a second download of Zola, the Linux copy GitHub will use; the script records its fingerprint so that GitHub can check it later. See “What the checksum proves”.

A re-run doesn’t commit anything, so commit the new files yourself, from inside the blog folder:

cd ~/Desktop/myblog
git add -A
git commit -m "Set up publishing"

git add -A prints nothing. git commit prints a line such as [main 1a2b3c4] Set up publishing and how many files changed.

Step 4: push

From inside the blog folder:

git push -u origin main

The first time, ssh may ask you to trust GitHub’s fingerprint (as in step 1), and asks for your key’s passphrase if you set one. git then prints some progress lines, ending with:

To github.com:John/notes.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

After this first time, git push on its own is enough. If git asks for a username and password, or says src refspec main does not match any, see Troubleshooting.

Step 5: switch on GitHub Pages

Your push started a run of the workflow on GitHub. If Pages isn’t switched on yet, that run fails at its deploy step, with an error that includes Ensure GitHub Pages has been enabled. That’s expected the first time. Switch it on:

  1. On the repository’s page, click Settings, then Pages in the list on the left.
  2. Under “Build and deployment”, set Source to “GitHub Actions”. Not “Deploy from a branch”: with that, GitHub builds the repository its own way, with a tool called Jekyll that doesn’t understand this blog.
  3. Click the Actions tab, click “Deploy to GitHub Pages” in the list on the left, then Run workflow, and Run workflow again in the box that opens. (Or open the failed run and choose to re-run its jobs.)

The run takes a minute or two. When it finishes with a green tick, your blog is live at https://John.github.io/notes/, with your username and repository name in place of these. The deploy step shows the address too.

Other guides sometimes say to set Settings, Actions, General, Workflow permissions to “Read and write”. Don’t. This workflow already asks for exactly the permissions it needs, only in the part that needs them; that setting would give every workflow in the repository, including any you add later, permission to change it.

Publishing changes from now on

Each time you want the live site to catch up with your computer, run these from inside the blog folder:

./build
git add -A
git commit -m "New post about tea"
git push

./build isn’t required, but it builds the site the way GitHub does, so a mistake shows up in seconds on your computer instead of as a failed run on GitHub. It should end with Done in .... The words after -m are a note to yourself about what changed. GitHub rebuilds and republishes in a minute or two; the Actions tab shows the progress.

git status tells you where you are. nothing to commit, working tree clean means every change is committed. If it also says Your branch is ahead of 'origin/main', you have commits that aren’t on GitHub yet, so run git push.

Making it yours

How changes work

There are three kinds of change, and each has its own place:

  1. Your writing and pictures, in content/ and static/, except static/favicon.svg and static/search.js, which are script-owned (see below). Edit these files directly, whenever you like. The script writes them once, when it creates the blog, and never touches them again.
  2. Settings, in the configuration block at the top of the script. Change a value, then run the script again with START_PREVIEW=false bash zola-blog-setup.sh. The run rewrites the generated files to match.
  3. The design and the page layouts, which also live in the script: each generated file is written by one render_ function. render_base_html writes templates/base.html, which holds the whole stylesheet; render_site_config writes config.toml; render_page_html writes the layout of a post. Change the text inside the function, then run the script again. A function starts with a line such as render_page_html() {, and the file’s text runs from the line after the one ending << 'EOF' (in some functions, << EOF) to the next line that is just EOF. You rarely need to find one by name, though: the sections below each give a piece of text to search for, which takes you to the line to change.

Run the script from the folder it’s saved in (cd ~/Downloads first), or from anywhere with its full path: START_PREVIEW=false bash ~/Downloads/zola-blog-setup.sh. From any other folder, plain bash zola-blog-setup.sh stops with No such file or directory. When the run finishes, a preview that’s already running reloads its page by itself; if none is running, start one with ~/Desktop/myblog/serve.

The generated files are script-owned: config.toml, everything in templates/, serve, build, new, attach, .gitignore, static/favicon.svg, static/search.js, and .github/workflows/deploy.yml. Every run writes them again from the script, so a change made to one of them directly lasts only until the next run. So that such a change isn’t lost without you noticing, a run stops before writing anything if one of them was edited by hand, and names the file. The script writes README.md too, but editing it doesn’t stop a run: the file becomes yours instead. See “The hand-edit check”.

A re-run changes files but doesn’t commit them. After it, check the preview, then commit and push as in “Publishing changes from now on”.

If you have a blog made with this script but don’t have the script itself, none of this rewriting happens, and you can edit config.toml and templates/ directly.

Settings, one by one

Each setting is one line in the configuration block. After changing one, run the script again. The defaults are the values the script comes with.

Where the blog lives

PROJECT_DIR: the folder that your blog’s folder goes in. Default: "$HOME/Desktop", your Desktop. You can start it with ~ for your home folder, as in PROJECT_DIR="~/blogs". A path that starts with neither / nor ~ counts from the folder you run the script in.

BLOG_NAME: the name of the blog’s own folder. Default: "myblog". Use letters, numbers, dashes and underscores; no spaces or slashes. The blog is at PROJECT_DIR/BLOG_NAME, for example ~/Desktop/myblog. Readers never see this name; they see SITE_TITLE.

The script finds your blog by these two settings. If you change either one after the blog exists, the next run doesn’t move the blog: it makes a second, new blog in the new place. To move or rename the blog, move or rename its folder as well.

Publishing

GIT_REPO_URL: your GitHub repository’s address, such as "https://github.com/John/notes". Default: "", empty, which gives a blog that lives only on your computer. Setting it does four things: it sets the site’s address (here https://John.github.io/notes), writes the workflow that publishes the site, connects git to the repository, and fills in the optional “View history” and “Suggest an edit” links on each post. It must be the https://github.com/ address of the repository’s main page, as described in “Putting it online”; a / or .git on the end is fine, and anything else after the repository’s name stops the run. Clearing it again removes the workflow; see “Turning something off removes its file”.

CUSTOM_DOMAIN: your own domain, once you have one pointed at GitHub Pages, such as "https://blog.example.com". Default: "". It changes the site’s address and nothing else, and it must start with https://. See “Custom domain”.

Your name and your words

SITE_TITLE: the blog’s name. It appears at the top left of every page, in the browser tab, and in feed readers, and the favicon’s letter comes from it. Default: "Myblog", a placeholder.

SITE_DESCRIPTION: one line about the blog. It appears in small italics under the blog’s name, on the home page only. It is also the feed’s subtitle, and the description search engines and link previews show for any page that doesn’t have its own description (see “A new post”). Default: "one-line description", a placeholder. Set it to "" for none.

SITE_AUTHOR: your name as it appears in the feed. Default: "your name". Always set it to something: the feed format requires an author, and without one the feed isn’t valid.

SITE_LANGUAGE: the language you write in, as a short code: "en" for English, "hi" for Hindi, "ta" for Tamil, "fr" for French, and so on. Default: "en". Browsers, screen readers and search engines use it to handle your text correctly.

How it looks

LIGHT_THEME: false (the default) makes the site dark for every reader. true adds a light version, which readers see when their computer or browser is set to light mode; readers set to dark still get the dark one. There’s no switch on the page for readers to change it themselves. See “Dark by default”.

The About page’s placeholder picture, static/avatar.svg, is drawn once, to suit this setting as it was when the blog was made. If you change LIGHT_THEME later, replace the placeholder with your own photo, which you’ll want to do anyway.

MASTHEAD: what shows at the top left of every page.

  • "text" (the default): the blog’s name in bold, in the same typeface as the text.
  • "none": nothing, so the top of the page shows only the menu. The name at the top left is normally also the link back to the home page, so with "none" the menu starts with a Home link instead.
  • "image": a logo you supply, as static/logo.svg. See “Using a logo”.

FAVICON_TEXT: what the favicon, the small icon in the browser tab, shows.

  • "auto" (the default): the first letter or digit of SITE_TITLE, as a capital. Only the letters A to Z and the digits 0 to 9 count, so a title with none of those (one written entirely in Devanagari, say) gets no favicon, with a warning, unless you set FAVICON_TEXT yourself.
  • Any one to three characters, such as "AN", drawn as you typed them. Anything longer is cut to three characters, with a warning.
  • "": the script draws no favicon, and you can supply your own. See “Using your own favicon”.

LIGHT_CODE_THEME and DARK_CODE_THEME: the colour schemes for code examples in posts. Defaults: "github-light" and "github-dark". Other pairs include "solarized-light" and "solarized-dark", "gruvbox-light-medium" and "gruvbox-dark-medium", or "one-light" and "one-dark-pro". The full list is in the “Themes” part of the README of Giallo, Zola’s highlighter, at github.com/getzola/giallo, and textmate-grammars-themes.netlify.app shows what each one looks like. A name that isn’t on the list stops the build with Theme `...` does not exist. With LIGHT_THEME=false, readers only ever see the dark one.

What’s on the pages

SHOW_TOC: true (the default) puts a Contents box at the top of every post that has a date and at least two headings (or one heading with sub-headings under it), listing the headings as links. It starts folded, as one line, “▸ Contents”, with a thin line under it, and opens when the reader clicks it. A page without a date, such as About, gets one only if it asks for it with toc = true; see “Adding a page to the menu”. false leaves them all out.

SHOW_BREADCRUMBS: true (the default) puts a line above the title of a page in a folder: Home, then each folder above the page, all of them links, so a page in content/projects/suite/ shows Home › Projects › Suite, and that folder’s own page shows Home › Projects. Posts and other pages at the top of content/ show nothing, and so do the pages of folders at the top, such as content/projects/_index.md, and pages in a folder with transparent = true, which are listed as posts. false leaves them all out. See “Child pages”.

SHOW_READING_TIME: true (the default) shows an estimated reading time, such as “3 min read”, on each post’s date line. Zola works it out from the number of words. false leaves it out.

DATE_FORMAT: how dates are written on the date line. Default: "%Y-%m-%d", which gives 2026-09-22. "%-d %B %Y" gives 22 September 2026, and "%d %b %Y" gives 22 Sep 2026. %d is the day (%-d without a leading zero), %B the month’s name, %b its first three letters, %m its number, and %Y the year. The value must have at least one % code, or the run stops. Browsers and search engines still get the date year-month-day, whatever this shows. For month names in another language, see “Changing the fixed words”.

ENABLE_TAGS: true (the default) turns on tags. Tags show on posts and in lists, the menu gets a Tags link, and each tag gets a page listing its posts. false turns all of that off. Posts that still have tags in their front matter keep working; the tags are ignored.

ENABLE_SEARCH: true (the default) puts a Search button at the start of the menu, which opens a search box over the page. This is the only script on the blog’s pages. false removes the button, the script and the search index, and the pages then run no JavaScript at all. See “Search”.

GENERATE_FEEDS: true (the default) publishes your feed at /atom.xml, a list of your posts that feed reader apps and email services check for new ones. While tags are on, each tag also gets a feed of its own. false publishes no feeds and removes the RSS link. Email updates need the feed; see “Email subscription”.

SHOW_RSS_LINK: true (the default) puts an RSS link in the menu. false removes just the link. The feed is still published, and feed readers still find it, because every page names it in its HTML head, the part of the page that browsers read but readers don’t see. It has no effect when GENERATE_FEEDS is false.

SHOW_HISTORY_LINK: false (the default). true adds a “View history” link to each post’s date line, leading to the list of commits that changed that post on GitHub. It needs GIT_REPO_URL. That list shows the name and email on each commit, which is one more reason to choose them with care (see “Your first blog”, step 1).

SHOW_SUGGEST_EDIT: false (the default). true adds a “Suggest an edit” link to each post’s date line, leading to the post’s file on GitHub, where a reader with a GitHub account can propose a change. It needs GIT_REPO_URL.

The menu

MENU_PAGES: the pages in the menu at the top of every page, by their file names in content/ without .md, separated by spaces, in the order the menu shows them. Default: "about". Each link is labelled with that page’s title. Search comes before them, and Tags and RSS after them, as their settings allow. See “Adding a page to the menu”.

FOOTER_LINKS: the links at the bottom of every page, each written as a label, =, and an address, with a space between one link and the next. Default: "X=https://x.com/yourhandle Nostr=https://nostr.com/npub1yourkeyhere GitHub=https://github.com/yourhandle/yourrepo", example addresses that lead nowhere. Put in your own addresses (for GitHub, the blog’s repository), delete the pairs you don’t want, or set it to "" for none. See “Footer text and links”.

FOOTER_TEXT: one line of plain text under the links, in smaller, fainter type, such as "© 2026 John". Default: "", none.

Settings for a single run

These two are easiest to set for one run only, by putting them in front of the command rather than changing the file:

START_PREVIEW: true (the default) starts the preview when the run ends. For one run without it: START_PREVIEW=false bash zola-blog-setup.sh. The preview is also skipped when the script’s output isn’t going to a terminal, for example when another script runs it.

REGENERATE_TEMPLATES: false (the default). true lets a run overwrite script-owned files even if they were edited by hand, and a README.md you’ve edited. For one run: REGENERATE_TEMPLATES=true bash zola-blog-setup.sh. See “The hand-edit check”.

One more, ZOLA_VERSION_OVERRIDE, is for the day a Zola newer than 0.23 comes out: the run stops then, and its message says how to use it.

Leave these alone

Further down the script, below the configuration block, ZOLA_MIN_VERSION and ZOLA_MAX_VERSION ("0.23" and "0.24") set which Zola versions the script accepts. The templates only work with Zola 0.23.x, so changing these without rewriting the templates gives you a blog that doesn’t build.

The home page and the About page

Home page text. Anything you write in content/_index.md, below its closing +++, appears on the home page above the list of posts. Leave it empty for just the list.

About page. Edit content/about.md. It’s an ordinary Markdown file with a title at the top. The round picture is set in its front matter; see “Your photo”. Like any menu page, it can have a subtitle and a Contents box; see “Adding a page to the menu”.

Adding a page to the menu

The menu at the top of each page shows Search, then the pages named in MENU_PAGES, then Tags and RSS, depending on your settings. MENU_PAGES starts as "about". To add a page of your own, such as a “Now” page:

  1. Make the page. In the blog folder, save a new file in content/ called now.md, containing:

    +++
    title = "Now"
    hidden = true
    +++
    
    What I'm working on this month.
    

    The file’s name becomes the page’s address, so content/now.md is published at /now/. The title is the word the menu shows. hidden = true keeps the page out of the list on the home page, the feed, search and sitemap.xml, so the menu is how readers reach it.

  2. Add it to the menu. In the script’s configuration block, find the MENU_PAGES line and add the page’s name, without .md, inside the quotes, after a space:

    readonly MENU_PAGES="about now"       # pages in content/, by name, in menu order
    

    The menu follows this order, so Now comes after About. Save the script.

  3. Apply it. From the folder where the script is saved, run it again:

    START_PREVIEW=false bash zola-blog-setup.sh
    

    It prints updated., then a note that it skipped the preview. If the preview is already running, its page reloads by itself, and with the default settings the menu then reads Search, About, Now, Tags, RSS. If the preview isn’t running, start it with ~/Desktop/myblog/serve.

If the page isn’t there, because step 1 was skipped or the name is misspelt, the run stops before changing anything:

MENU_PAGES names nwo, but there's no content/nwo.md. make that page first (guide: "Adding a page to the menu"), or take nwo out of MENU_PAGES. nothing has been changed.

It stops the same way, naming the page, if the page doesn’t say hidden = true. Without it the page counts as a post: listed on the home page if it has a weight, and left out of the site if it hasn’t, so ./build fails with a message about pages that produced no HTML.

On a new blog, the first run makes a starter page for each name in MENU_PAGES other than about: with MENU_PAGES="about now" set before the first run, you get content/now.md, titled “Now”, ready to fill in.

To take a page out of the menu, delete its name from MENU_PAGES and run the script again. The page itself stays, at its address. MENU_PAGES="" leaves only Search, Tags and RSS.

The menu shows only the blog’s own pages. A link to another site goes in the footer instead; see “Footer text and links”. A menu page with pages of its own under it is a folder instead, content/projects/_index.md, without hidden = true, and MENU_PAGES takes it by the same name, projects; “Child pages” says how to make one, or turn a menu page into one. A folder in the menu isn’t also listed on the home page. Don’t keep both projects.md and projects/_index.md: Zola won’t build two pages at one address, and the run stops and says so.

A subtitle and a Contents box. A menu page can have both, as a post can. For a subtitle under the title, add a description line to its front matter. For a Contents box listing the page’s headings, add toc = true under an [extra] line. Keep [extra] below title, description and hidden, because everything written after it counts as part of it:

+++
title = "Now"
description = "What I'm working on this month"
hidden = true
[extra]
toc = true
+++

If the front matter already has an [extra] line, as the About page’s does, put toc = true under that one instead: a second [extra] line stops the build with Error when parsing front matter of page. The box appears only while SHOW_TOC is on, and only if the page has at least two headings, or one with sub-headings under it. The subtitle is also what search engines and link previews show for the page.

The footer shows links, from FOOTER_LINKS, and under them a line of small print, from FOOTER_TEXT. Either can be empty; with both empty, there’s no footer.

Each link is a label, an =, and an address, and a space separates one link from the next. The script starts you with example links for X, Nostr and GitHub, the GitHub one for the blog’s repository. To drop a link, delete its pair. This keeps X and GitHub and drops Nostr:

readonly FOOTER_LINKS="X=https://x.com/John GitHub=https://github.com/John/notes"

To add a link, add a pair where you want it to appear. This adds Mastodon after X:

readonly FOOTER_LINKS="X=https://x.com/John Mastodon=https://mastodon.social/@John GitHub=https://github.com/John/notes"

FOOTER_LINKS="" drops every link. Save the script and run it again after each change. Each label is one word. Each address must start with https://, http:// or mailto: (for an email link, Email=mailto:you@example.com); anything else stops the run with a message that names the link, because an address without one would lead to a page inside your own blog.

The small print is one line, such as a copyright notice, shown under the links in smaller, fainter type:

readonly FOOTER_TEXT="© 2026 John"

It’s shown as plain text: HTML in it appears as those characters, so type a sign such as © itself, not &copy;. The year doesn’t change by itself.

To show a straight quote, ", put a backslash before it, as anywhere in the configuration block. Doubling it, "", doesn’t work:

readonly FOOTER_TEXT="© 2026 John · \"Soli Deo Gloria\""

Curly quotes, “ and ”, need no backslash.

Your photo

The About page shows a round placeholder picture. To use a photo of yourself instead:

  1. Copy the photo into the blog, without its hidden data. From inside the blog folder, with your photo’s path in place of ~/Pictures/me.jpg:

    ./attach ~/Pictures/me.jpg me
    

    It prints copied to content/attachments/me.jpg, without its location and camera data. in a post, write: and a line for posts, which you don’t need here. Your original file isn’t changed.

    Photos from phones and cameras carry hidden data called EXIF, which often includes where and when the photo was taken, and the phone or camera model. Everything you commit is published, in your public repository and on the site, so ./attach removes that data on the way in. It keeps only the tag that says which way up the photo goes, so the photo isn’t shown sideways. It needs exiftool (on Debian, Devuan or Ubuntu, sudo apt install libimage-exiftool-perl; on a Mac, brew install exiftool); without it, ./attach says how to install it and copies nothing.

  2. Name the photo in the About page. In content/about.md, find avatar_photo = "" in the [extra] part and change it to:

    avatar_photo = "attachments/me.jpg"
    

    The path starts from the content folder, so a bare "me.jpg" isn’t found and the build stops with `resize_image`: Cannot find file: me.jpg.

When it builds the site, Zola makes a small square copy of the photo (240 by 240 pixels, shown at 120), so readers download a small file. The original is published too, at /attachments/me.jpg, which is why step 1 cleans it first.

Without ./attach. The same cleaning is one exiftool command, run from inside the blog folder. It writes a cleaned copy into the attachments folder and leaves your original as it was:

exiftool -all= -tagsfromfile @ -orientation -o content/attachments/me.jpg ~/Pictures/me.jpg

It prints 1 image files created. To check the copy, run exiftool content/attachments/me.jpg: no line in its output should start with GPS, Make, Camera Model Name, or Date/Time Original.

The other key, avatar = "avatar.svg", is the placeholder. It names a file in static/, which is shown as it is, without being shrunk. Use it for an SVG drawing (a picture made of shapes rather than dots, which most drawing programs can save), because Zola can’t shrink those. If both keys are set, avatar_photo wins.

Pictures, GIFs and video

Pictures, GIFs and videos all go in one folder, content/attachments/, and any post or page can use them.

To put a picture in a post, add it with the blog’s attach helper, from inside the blog folder, giving the picture’s path and a new name for it:

./attach ~/Pictures/IMG_0001.jpg tea-hills

It prints:

copied to content/attachments/tea-hills.jpg, without its location and camera data. in a post, write:
![what it shows](@/attachments/tea-hills.jpg)

Paste the second line into the post, and replace “what it shows” with a description of the picture:

![Hills at sunrise, seen from the ridge](@/attachments/tea-hills.jpg)

The helper removes the photo’s hidden data (see “Your photo”, step 1), keeps the tag that says which way up it goes, and leaves your original as it was. The file keeps its own extension, in lower case. Every file shares this one folder, so two files can’t have the same name: IMG_0001.jpg from two phones would clash, which is why you give each a new name, after its post, in lowercase with dashes. The helper refuses a name that’s taken, and refuses HEIC pictures, which most browsers can’t show; export those as JPEG first. It needs exiftool; if it isn’t installed, ./attach says how to install it. To do it without the helper, use the exiftool command at the end of “Your photo”, with the picture’s path and its new name.

An SVG drawing is the one kind of picture that neither ./attach nor exiftool can clean, so ./attach says so and copies nothing. Save the drawing as a plain SVG instead (in Inkscape, File, then Save As, with Plain SVG as the type), which leaves out Inkscape’s own notes, such as the file’s name and a folder on your computer. Then copy it into content/attachments/ yourself, under a new name, and link to it as above.

The words in square brackets describe the picture for readers who can’t see it, including blind readers using screen readers, so always fill them in. @/ means “starting from the content folder”. Zola turns the path into the picture’s full address, so it works at any site address and in feed readers, and if the name is wrong, the build stops with Broken relative link instead of publishing a missing picture. A picture never shows wider than the text column, or taller than 85% of the window; a big photo is shrunk to fit.

A GIF goes in the same way, as a picture. A video needs a line of its own, because Markdown has no way to show one:

{{<video src="attachments/tea-bus.mp4" />}}

That shows the video with its play button and controls. ./attach works for MP4 and MOV videos too, and prints this line for them; it can’t clean WebM videos, because exiftool can’t write them. To have a video play by itself, silently and on a loop, like a GIF, add loop="yes":

{{<video src="attachments/tea-bus.mp4" loop="yes" />}}

Here the path starts from the content folder without @/. If the file isn’t there, the build stops with `get_url`: could not resolve URL for link `@/attachments/tea-bus.mp4` not found. The same clip is usually much smaller as a video file than as a GIF, so for anything longer than a moment, use a video with loop="yes". A looping video still has its controls, which show when the reader points at it or taps it, so anyone distracted by the movement can pause it. A GIF can’t be paused in any browser, which is one more reason to use a video for anything that loops for more than a few seconds.

Mind the size of videos. The blog lives in its GitHub repository, every version of every file. GitHub warns about any file over 50 MiB and refuses one over 100 MiB. It recommends keeping a repository under 1 GB, and won’t publish a site over 1 GB. git also keeps every file you’ve committed in the repository’s history, so deleting a video later doesn’t give the space back. Short clips are fine. A long video belongs on a video site, and showing it in a page from there means loosening the security policy; see “The security policy”.

What makes the folder work. content/attachments/_index.md, which the script writes for a new blog, tells Zola to publish the folder. Its redirect_to = "/" sends anyone who opens /attachments/ to the home page, and in_search_index = false keeps it out of search. The address /attachments/ still appears in sitemap.xml, the list of pages that search engines read, as a redirect: Zola can’t leave one folder out of the sitemap. That’s harmless. Don’t add render = false to that file: Zola then publishes nothing in the folder, and every @/attachments/ link stops the build.

If content/attachments/_index.md goes missing, each run warns you, and every @/attachments/ link stops the build. To put it back, save this in the folder as _index.md:

+++
title = "Attachments"
sort_by = "none"
in_search_index = false
redirect_to = "/"
+++

Don’t put pictures in static/ and refer to them with a path starting with /, such as /hills.jpg. A path like that points at the root of your web address, and on an address such as John.github.io/notes the root is John.github.io, outside your blog, so the picture shows in the preview but not on the published site.

Fonts

The design uses a typeface called Charter. It doesn’t come with the script; you download it yourself, and until you do, readers see the first of several similar typefaces that their own computer has. That fallback can look quite different. In the text column, Charter fits about 79 characters to a line, Palatino 76 and DejaVu Serif 68. A Times-like typeface such as Liberation Serif, common on Linux, fits about 85, which is too many for comfortable reading. So install Charter:

  1. Download it from practicaltypography.com/charter.html. It’s free, including for commercial use. The download is a zip file with several formats in it. Open it (double-click it in your file manager) and find the four files whose names end in .woff2.

  2. Copy the four .woff2 files (regular, italic, bold, and bold italic) into your blog’s static/fonts/ folder, named exactly:

    charter_regular.woff2
    charter_italic.woff2
    charter_bold.woff2
    charter_bold_italic.woff2
    

    Rename them if they’re called something else.

  3. Reload the page in your browser (F5). The text should change typeface.

static/fonts/ is yours, so re-runs never touch these files. static/fonts/README.txt is published along with everything else in static/, at /fonts/README.txt. It’s harmless, but you can delete it once the fonts are in.

Don’t load fonts from a font service such as Google Fonts. Each reader’s browser would then contact that service, which would learn their IP address and which page they were reading. The site’s security policy blocks this anyway, unless you change the policy.

Colours, sizes and typefaces

The whole stylesheet is in templates/base.html, which the script writes from render_base_html. So make design changes in the script, in render_base_html, not in templates/base.html. Its first part, between the comments YOUR DESIGN and END DESIGN, holds the settings you’re likely to want. The rest, marked STRUCTURE, is the layout, which reads those settings; leave it alone unless you know CSS.

The settings are called custom properties, lines like --accent: #ffb454;. Colours are written as hex codes (#ffb454 is an orange); any colour picker shows the code for a colour. Sizes use rem, which is relative to the reader’s standard text size (usually 16 pixels, so 1.2rem is about 19 pixels), and em, which is relative to the text around it.

SettingWhat it controlsDark valueLight value
--bgPage background#111#fffff8
--fgText#e8e6da#111
--mutedQuieter text: dates, menu, footer, notes#aaa8a0#57564e
--linkLinks in your text, once the reader has followed them. Until then a link takes the colour of the text around it; either way it’s underlined. Footnote numbers, which have no underline, have this colour whether followed or not. The links the site adds by itself, such as its lists of posts, child pages, tags and search results, the menu, Previous and Next, and the footer, keep their own colours, followed or not#94b9dc#295a8e
--accentCode words in sentences, the outline around the search box while you type in it, and the “(draft)” label#ffb454#9d4909
--surfaceTable header rows#242420#f4f2e8
--inline-bg, --inline-borderBackground and border of code words in sentences#2b281f, #3a382f#ece3cd, #e0d8c2
--borderThin lines around tables, quotes, code examples and boxes#2c2c28#e5e3d7
--border-strongThe lines under titles and under the Contents box, and dividers#42423c#c2bda4
--control-borderThe search box’s border, dark enough to see (3:1 against the page, as accessibility guidelines ask)#6e6c64#8a8676
--hairlineThe style of those lines; none removes them all1px solid var(--border-strong)
--measureWidth of the text column42rem
--wideWidest a wide page or wide element can go62rem
--body-size, --body-leadingText size and line spacing1.2rem, 1.55
--wordmark-sizeSize of the blog’s name at the top left1.5em
--h1-size, --h2-size, --h3-sizeHeading sizes. On screens narrower than 576 pixels, --h1-size is 1.9rem, set just below the light list2.25rem, 1.7rem, 1.35rem
--serif, --monoTypefaces for text and for code, first choice firstCharter, …; system monospace, …

The dark values come first in the block. The light values, used only when LIGHT_THEME=true, are in a second list just below it, headed @media (prefers-color-scheme: light). With the light version on, change a colour in both lists.

For example, to make the accent colour teal:

  1. Open the script and search for --accent: #ffb454.
  2. Change it to --accent: #4fd1c5; and save.
  3. Run the script again with START_PREVIEW=false bash zola-blog-setup.sh. If the preview is running, it reloads by itself; if not, start it with ~/Desktop/myblog/serve. Code words in the example post are now teal.

Keep --wordmark-size between about 1.45em and 1.85em. Below that, headings inside posts are bigger than the blog’s name; above it, the name is bigger than each post’s title. If you make --h2-size bigger, the lower limit goes up with it: the name has to stay bigger than an h2.

For a bigger redesign, copy everything from YOUR DESIGN to END DESIGN, give it to an AI model with a request such as “restyle this to look like an old typewritten letter, and leave everything inside {{ }} and {% %} exactly as it is”, and paste the result back in place of the original. That last part matters: the block has template code in it, in the font lines and around the light colours, and changing that breaks the fonts or the build. Code examples don’t follow these colours; they use LIGHT_CODE_THEME and DARK_CODE_THEME.

With MASTHEAD="image", the top left shows static/logo.svg, which you supply. The logo is used as a stencil: its shape is filled with the text colour, so the same file works on dark and light pages. Make it a flat shape, such as a monogram, on a transparent background, and save it as an SVG file. Any background in the file counts as part of the shape, so the whole box comes out solid. If the file is missing, the top left shows an empty space, and each run warns you about it. The logo’s size follows --wordmark-size.

Using your own favicon

The script draws static/favicon.svg from FAVICON_TEXT, and draws it again on every run, so change the setting, not the file. The letter is dark, and turns light when the reader’s browser or computer is in dark mode, because the tab bar it sits in follows that setting even when your site is dark for everyone.

To use a favicon of your own, set FAVICON_TEXT="", put your file in place as static/favicon.svg, and run the script. The run deletes the favicon only if it’s still exactly the one the script drew, so your own file is safe, and from then on the script doesn’t write, check or delete it. Don’t swap the file while FAVICON_TEXT is still set: the run then stops at the hand-edit check, and REGENERATE_TEMPLATES=true would draw over your file.

A favicon can’t use Charter or any other web font, because browsers load it as an image, and images can’t load fonts. The drawn one uses Palatino, Georgia or another serif typeface, depending on what the reader’s computer has.

Dark by default

With LIGHT_THEME=false the site is dark for every reader, whatever their computer is set to. With true, a reader whose computer or browser is set to light mode gets the light version, and everyone else gets the dark one. There is no switch on the page: that would need a script on every page, or a cookie.

Code examples follow the same rule. The highlighter writes both a light and a dark colour into every code example, and with LIGHT_THEME=false the stylesheet forces the dark ones, so a reader in light mode doesn’t get pale code on a dark page.

If dark mode looks wrong on your own computer but right on others, suspect your desktop’s settings rather than the blog. On some Linux desktops, including XFCE, the browser can’t always tell whether you prefer dark and guesses from your desktop theme. To test the light version, use the browser’s developer tools, which can pretend the computer prefers light or dark.

Changing the fixed words

The words the site shows by itself, such as “All posts” or “min read”, are written into the templates. To change them, for example to write your blog in another language, search the script for the text in the right-hand column, change the words on that line, and run the script again. Each search finds one line, except where the table says otherwise.

Words on the siteSearch the script for
The names of your pages in the menuEach page’s title, in its file in content/
Tags, RSS (the menu), and Home (the menu’s first link with MASTHEAD="none", and the first link above the title of a page in a folder)>Tags</a>, >RSS</a>, >Home</a>, which finds three lines: the menu’s, the one above the title, and the one on the “page not found” page
The footer’s link labels and small printThe FOOTER_LINKS and FOOTER_TEXT settings
Search (the button), and the empty search box’s grey hint>Search</button> and placeholder="Search"
nothing matches, search is unavailableThe words themselves
1 result, results (read out to screen readers as you search)1 result, the line with both
Contents, Updated, min read, View history, Suggest an edit>Contents<, Updated <time, min read<, >View history<, >Suggest an edit<
(draft)>(draft)<, which finds two lines: a row in a list, and a post’s own title
Tags:, Previous, All posts, Next (at the bottom of a post; the last three also under a folder’s list)>Tags:, Previous<, >All posts<, >Next<
Tags (the heading of the tags page, and its name in the browser tab)>Tags</h1> and title %}Tags
Posts tagged:, post, posts, All tagsPosts tagged:, which finds two lines (change both), post{% if, All tags
The “page not found” pagePage not found for its title, This page isn't here for its text
Site, Previous and next, Breadcrumb, contents, Search (the names screen readers give the three menus, the Contents box and the search box)aria-label="Site", aria-label="Previous and next", aria-label="Breadcrumb", aria-label="contents", aria-label="Search"

Change only the words, not the {{ }} or {% %} parts around them, and keep any quotes in place. For example, to change the grey hint in the search box, search for placeholder="Search", which finds this line:

<input type="search" aria-label="Search this site" placeholder="Search" autocomplete="off">

Change the text after placeholder=, like this:

<input type="search" aria-label="Search this site" placeholder="Search the notes" autocomplete="off">

aria-label is what a screen reader announces for the box; change it too if it no longer fits. Don’t use a " inside either value.

Dates follow the DATE_FORMAT setting. For month names in another language, search the script for date(format=config.extra.date_format), which finds two lines, and change it in both to:

date(format="d MMMM y", locale="hi_IN")

That shows 22 सितंबर 2026; use ta_IN for Tamil, fr_FR for French, and so on. With a locale, the format is written this other way, d MMMM y, not with % codes, and DATE_FORMAT no longer applies. The locale option needs Zola 0.23.5 or later: on 0.23.4 and earlier the date comes out as the letters d MMMM y, with no error. zola --version shows which you have, and “Moving to a newer Zola” says how to move on.

Writing posts

A new post

A post is one Markdown file in content/. The file’s name, without .md, becomes the post’s address, so content/tea-in-the-hills.md is published at /tea-in-the-hills/. Use lowercase letters, numbers and dashes in file names.

Every post starts with its front matter, between two lines of +++:

+++
title = "Tea in the hills"
description = "Notes from a week above the clouds"
weight = 30
date = 2026-09-22
[taxonomies]
tags = ["travel", "tea"]
+++

The first paragraph of the post.

Save the file, and the preview shows the new post in the list on the home page.

  • title: the post’s title, in quotes. It can only go here: Zola doesn’t take a title from a # heading in the text, and a post without a title shows up in the list as a number with nothing after it.
  • description (optional): a subtitle, shown under the title in quieter italic. It’s also what search engines and link previews show for the post, in place of SITE_DESCRIPTION.
  • weight: where the post goes in the list, as a whole number without quotes; lower numbers come first. Every post needs one. A post without a weight is left out of the site, and ./build stops with a message saying so. See “Ordering posts”.
  • date: the date shown under the title, written year-month-day. The feed needs it too: a post without a date isn’t in the feed, so it never reaches feed readers or email subscribers. A post without a date also has no date line, no reading time and no Contents box, but it’s still in the list on the home page, with Previous and Next links. Give every post a date unless you have a reason not to.
  • updated (optional): when you last changed the post, in the same format. If it’s different from date, the post shows both. It doesn’t move the post in the list.
  • [taxonomies] and tags (optional): the post’s tags, in quotes, separated by commas. Keep these two lines last in the front matter, because everything written after [taxonomies] counts as part of it. A tags line without [taxonomies] above it is ignored, with no error, and the post has no tags.

Text goes in quotes, like the title; numbers, dates, true and false go without. A slip here stops the build with Error when parsing front matter; see Troubleshooting.

The date line also shows an estimated reading time, which Zola works out from the text. SHOW_READING_TIME=false leaves it out.

Two more front matter lines can be useful. Put them above [taxonomies]:

  • draft = true leaves the post out of the published site. The preview still shows it, with “(draft)” after its title in the list and on the post, and counts it in the numbering and in the Previous and Next links, which the published site won’t. Once committed, the file is still in your repository, which is public, so a draft isn’t secret.
  • slug = "tea" changes the post’s address to /tea/ without renaming the file.

The quick way to start a post. The blog’s new helper writes the front matter for you. From inside the blog folder, give it the post’s file name, without .md:

./new tea-in-the-hills

On a new blog, where the two example posts have the weights 10 and 20, it prints:

made content/tea-in-the-hills.md, a draft at weight 30: ./serve shows it, and deleting its draft line publishes it.

The file has a title made from the name, “Tea in the hills”, the next free weight, which puts the post at the end of the list, today’s date, and draft = true. Change the title as you like, add a description or tags if you want them, and write the post below the block. When it’s ready, delete the draft = true line and change date to that day. Until you do, the date is the day you started the post, and an email service can skip a post whose date is more than a day old; see “How a new post reaches subscribers”. The name takes lowercase letters, digits and dashes, and the helper refuses one that’s already taken.

Ordering posts

The home page lists posts by weight, lightest first, and numbers them 1, 2, 3 in that order. Nothing else changes the order: editing a post, or adding an updated date, doesn’t move it. A folder with child pages is numbered among the posts by the weight in its _index.md, the same way; see “Child pages”.

Number the weights 10, 20, 30 rather than 1, 2, 3. Then a post that belongs between two others can have 15, and no other file needs changing. Readers never see the weights, only the position in the list, so gaps don’t show, and deleting a post renumbers the rest. Addresses come from file names, so reordering never changes them. ./new works this way too: it gives a new post a weight 10 more than the heaviest post or folder, so the post goes at the end of the list.

The Previous and Next links at the bottom of each post follow the same order, and go through folders too: Next from the entry above a folder leads to the folder’s page, then down the folder’s own list, into any folders inside it, and from the end of that list on to the entry after the folder. Previous goes back the same way. So from the first entry, Next after Next reads everything in the order the lists show it, to the end. The first entry’s Previous is the home page, and every other post, child page and folder’s page has an All posts link back to it.

To list posts by date instead, newest first, open content/_index.md (it’s yours, so edit it directly) and change sort_by = "weight" to sort_by = "date". Every post then needs a date, and weights are ignored, except a folder’s: a folder has no date, so the folders come first, in the order of their weights. sort_by = "update_date" does the same but uses updated when a post has one, so a post you give a newer updated date moves back to the top. Posts stay numbered; to show bullets instead, change <ol class="post-list"> and its closing </ol> to <ul class="post-list"> and </ul> in render_components in the script, and run it again.

Markdown in brief

The most common things you’ll write:

You typeYou get
## HeadingA heading. Use ## and ###; the post’s title is already the top heading.
**bold**, *italic*bold, italic
[link text](https://example.com)A link
https://example.com, or <https://example.com>A link that shows the address itself
- itemA bulleted list
1. itemA numbered list
> quoted textA quotation
--- on a line of its own, with a blank line above itA dividing line; straight after a heading, a line under the heading
A blank lineA new paragraph

The example post, content/hello-world.md, shows all of these and more: tables, code, footnotes, notes, and pictures. Open it in your editor next to its page in the preview to see how each part is written.

To another post, write @/ and the file’s path inside content/: [the second post](@/second-post.md). The link comes out right whatever your site’s address, and if the file doesn’t exist the build stops with Broken relative link, so a typo can’t reach the published site.

Not like this: [the second post](second-post.md) or [the second post](./second-post.md). Zola doesn’t turn a path to a .md file into the address of its page, so the link points at a file that isn’t published, and readers get a “page not found”. It isn’t checked when the site is built either. Links to your own pages start with @/, Zola’s own sign for “find this file and link to its page”, and the path after it starts from the content folder, wherever the page with the link is.

To a folder’s page, name its _index.md: [the projects](@/projects/_index.md).

To a heading. Every heading gets an id made from its text, so ## Ingredients and tools gets #ingredients-and-tools. Link to it in the same post with [the ingredients](#ingredients-and-tools), or in another post with [the method](@/recipes/sourdough.md#method). The build also stops if a heading named in an @/ link doesn’t exist. If you might reword a heading later, give it a fixed id so links to it keep working:

## Ingredients and tools {#ingredients}

To other websites, write ordinary links. They open in a new tab, and Zola adds rel="noopener nofollow noreferrer external" to each one. That stops the other site learning which of your pages the reader came from, and tells search engines the link isn’t an endorsement.

An address on its own is a link too. https://example.com, pasted into a sentence as it is, becomes a link that shows the address, the same as <https://example.com>, and opens in a new tab like other links to other sites. A full stop, comma or closing bracket straight after it stays outside the link, unless the bracket belongs to the address, as in some Wikipedia addresses. It needs the https:// or http:// at the front: for example.com alone, write <https://example.com>, or give it words, as in [the dictionary entry](https://example.com). An address in code, between backticks or in a code example, stays plain text.

Headings don’t have link symbols beside them. To get a link to a section, open the post’s Contents box, right-click the heading there, and copy the link.

Code examples

Put code between two lines of three backticks, with the language’s name after the first ones, and it gets coloured:

```python
def greet(name):
    print(f"hello, {name}")
```

Without a language name, the code is shown plain, without colours. You can add options after the language name, separated by commas:

  • linenos numbers the lines, and linenostart=20 starts the numbering at 20.
  • hl_lines=2 4-5 highlights lines 2, 4 and 5 with a background colour from the code theme.
  • hide_lines=3 leaves line 3 out.

For example, ```python,linenos,hl_lines=2.

There’s no copy button; readers select code the usual way, and can select part of a line.

Text that looks like template code. Zola 0.23 reads posts as templates, so {{ and {% have a special meaning in a post, even inside a code example, and so does {# in any post that has either of them. A post showing a GitHub Actions setting such as ${{ steps.deployment.outputs.page_url }} stops the build with an error like Variable `steps` is not defined, and other template markers can quietly be replaced by something else. To show such text as it is, put {% raw %} on a line before it and {% endraw %} on a line after:

{% raw %}
```yaml
url: ${{ steps.deployment.outputs.page_url }}
```
{% endraw %}

For a post with a lot of such text, you can switch this off for the whole file instead. Search the script for build_search_index =, add this line directly below that one, naming the file from content/, and run the script again:

skip_content_templating = ["actions-notes.md"]

Zola then reads that post as plain Markdown, and {{, {% and {# in it are shown as they are.

Notes, fold-away sections, and wide items

Four extras, all written as small pieces of HTML in the post:

<aside>

A smaller, quieter passage, with a line down its left side.

</aside>

<details>
<summary>Show the working</summary>

Hidden until the reader clicks "Show the working".

</details>

The blank lines matter. Without them, the Markdown inside isn’t read as Markdown, and asterisks and brackets show up on the page as they are. So this doesn’t work:

<aside>
A note with *emphasis*.
</aside>

For more width:

  • <div class="wide" hidden></div>, anywhere in a post, makes that whole post wider, running text included. It’s for posts with big tables or pictures throughout.
  • class="bleed" on a single element lets just that element use the extra width, while the text stays at its normal width. Put it on a <figure> or a <div> at the top level of the post (not inside a list or quote). For a Markdown table, wrap it in <div class="bleed"> and </div>, with a blank line after the opening tag and another before the closing one.

If a post uses both, bleed wins and the text stays at its normal width. The text column is --measure (42rem) wide, and the extra width goes up to --wide (62rem).

Numbered outlines

To number a list as 1, 1.1, 1.2, 1.2.1, 2 and so on, number each level the ordinary way, 1., 2., 3., and indent each level under the one above:

1. First
    1. Sub one
    2. Sub two
        1. Detail
2. Second

This shows as 1, 1.1, 1.2, 1.2.1, 2. The numbers are worked out for you, so adding or removing an item renumbers everything after it. The same text shows as an ordinary nested list anywhere else, such as on GitHub.

Don’t type 1.1 yourself. Markdown doesn’t know it as a list number, so a line starting 1.1 is joined onto the item above it as ordinary text. This list, for example, comes out as two items, the first reading “First 1.1 Sub one 1.2 Sub two”:

1. First
    1.1 Sub one
    1.2 Sub two
2. Second

Indent each level by four spaces or one tab. Two spaces isn’t enough for a numbered list, even though it is for a bulleted one: the line becomes the next item at the same level instead of a sub-item, so this shows as 1 and 2, not 1 and 1.1:

1. First
  1. Sub one

If your editor’s Tab key puts in two spaces, set it to four for Markdown files, or type four spaces.

A numbered list is broken by anything that isn’t indented under it, such as a code example or a paragraph at the left margin. When the list carries on after the break, its sub-items are numbered plainly (1, 2) rather than as 3.1, 3.2, because the numbering can’t see where the list restarted. Indent the code or paragraph under its list item to keep the list in one piece.

Footnotes

Write text[^1] where the note belongs and [^1]: The note. on a line of its own, anywhere in the post. The notes are collected at the bottom of the post, each with a link back to where it was mentioned.

Child pages

A child page sits under another page, its parent, which lists it: /projects/ listing /projects/tool-a/ and /projects/tool-b/, for example. The parent is a section, a folder with an _index.md in it, and its children are the pages in that folder:

content/projects/_index.md          the parent, published at /projects/
content/projects/tool-a.md          a child, at /projects/tool-a/
content/projects/tool-b.md          another child, at /projects/tool-b/

You write the files, and the parent makes the list by itself, under its own text. Only a page with pages under it needs a folder. Every other page, menu pages included, is a single .md file.

To make that Projects page:

  1. Make the parent. In the blog’s content/ folder, make a folder called projects, and save a file in it called _index.md, with the underscore, containing:

    +++
    title = "Projects"
    sort_by = "weight"
    +++
    
    Things I have built.
    

    The folder’s name becomes the page’s address, /projects/, and the text below the closing +++ is shown above the list. sort_by = "weight" lets you choose the order of the list. Leave out hidden = true, which a menu page has: in a parent, it empties the list; see “When the list doesn’t appear” below.

  2. Add the children. Save each child in the same folder, written like a post, with a title and a weight:

    +++
    title = "Tool A"
    weight = 10
    +++
    
    What Tool A does.
    

    Saved as content/projects/tool-a.md, it’s published at /projects/tool-a/. Save tool-b.md beside it the same way, with weight = 20. Lighter weights come first, as on the home page; see “Ordering posts”. ./new makes only posts, so write a child’s file yourself.

  3. Look at it. With the preview running, open http://127.0.0.1:1111/projects/. It shows the title and your text, then a numbered list: Tool A first, Tool B second. Each title opens its page. Above each child’s title is Home › Projects, links to the home page and back to this page. At the bottom of each child are Previous and Next links, and the All posts link, which goes to the home page. The Projects page has Previous and Next links too, under its list. They read the list in order: Projects, Tool A, Tool B, then on to whatever follows Projects on the home page.

The home page lists the parent by itself, with no link to write, numbered among your posts by the weight in its _index.md: with weight = 25, Projects comes between the posts weighing 20 and 30. On an equal weight the folder comes first, and a folder without a weight comes before every post. Child pages aren’t in the home page’s list; their parent’s page lists them. To keep a parent off the home page, put it in the menu instead, by adding projects to MENU_PAGES: a folder in the menu isn’t in the list, and the Previous and Next links of its pages stay within its own list. See “Adding a page to the menu”.

What the list shows. Every page in the parent’s folder, with any folders inside it numbered among them by weight, as on the home page, except:

  • a page with hidden = true, which is published but listed nowhere; see “Pages that aren’t posts”;
  • a draft, which only the preview lists, marked “(draft)”;
  • a page without a weight, while the parent sorts by weight: Zola leaves it out of the site, and ./build stops with a message naming it.

When the list doesn’t appear. One of three slips:

  • The parent’s _index.md says hidden = true. Its children take it on: the parent lists none of them, they lose their Previous and Next links, and they drop out of search, the feed and sitemap.xml, though they’re still published. Delete the line.
  • The file is index.md, without the underscore. A folder with index.md is just one page, with any files beside it, and it can’t have children: a Markdown file put beside its index.md is published, but nothing lists it. Rename it _index.md.
  • The folder has no _index.md. Its pages are still published at their addresses, but nothing lists them, and /projects/ itself is “page not found”.

Turning a menu page into a parent. A menu page such as content/projects.md is a single file, and a single file can’t have children. To give it some:

  1. Move it into a folder. Make the folder content/projects/, move projects.md into it, and rename it _index.md.
  2. Make it a parent. In that file, delete the hidden = true line, and add sort_by = "weight" below the title.
  3. Apply it. From the folder where the script is saved, run it again with START_PREVIEW=false bash zola-blog-setup.sh. It prints updated., then a note that it skipped the preview. MENU_PAGES keeps the name projects, and the run points the menu at the folder. Until it does, the preview and ./build fail with Page `projects.md` not found.
  4. Add the children, as in step 2 above.

Order. sort_by goes in the parent’s _index.md only; in a child it does nothing. Without it, children need no weight, but Zola doesn’t promise any order for the list, and before Zola 0.23.4 the order can change from one build to the next. Any folders come first, and Previous and Next follow the list as it comes out. The parent takes no date: a section’s front matter doesn’t have one, and a date line in an _index.md stops the build with unknown field `date`.

Links to children. Don’t type a list of the children into the parent’s text. The parent’s own list is already there, and a typed one only repeats it. To link to one child from the parent’s text or anywhere else, use its path from content/: [tool A](@/projects/tool-a.md). A link written as (tool-a.md) or (./tool-a.md) leads to “page not found”; see “Links”.

In every other way a child is like a post: tags and search work the same, and a child with a date gets the date line, the Contents box if it has two or more headings, and a place in your feed, so feed readers and email subscribers get it. Leave the date off a child page you don’t want sent out.

Two variations:

  • Children of a child. Make the child a folder with an _index.md too, such as content/projects/suite/_index.md, and put its own children in that folder. The parent’s list numbers it among the parent’s own child pages by the weight in its _index.md, as the home page does, and Previous and Next go through its children before going on. Its children show both folders above their titles, after Home: Home › Projects › Suite.
  • A folder of posts. To show a folder’s pages in the home page’s list as well, as posts, add transparent = true to the folder’s _index.md. They’re then numbered among the posts by their weights, their Previous and Next links follow the home page’s order, and the folder’s own page still lists them. The folder itself isn’t in the home page’s list then, since its pages are. Nor is a folder inside it; that inner folder’s pages get Previous and Next links within its own list only. Like posts, its pages show nothing above their titles, not even Home.

Pages that aren’t posts

For a page that should exist and be linkable, but not appear in the home page list, the feed, search or sitemap.xml (a colophon, say, or a page for a talk), add hidden = true to its front matter:

+++
title = "Colophon"
hidden = true
+++

Saved as content/colophon.md, it’s published at /colophon/. It needs no weight or date. It gets no Previous, Next or All posts links at the bottom. Without a date, its title is set like a section’s, with a line under it, and it gets a Contents box only if it asks with toc = true under [extra], as a menu page does. Link to it from anywhere with [colophon](@/colophon.md). The About page is a hidden page too.

Hidden doesn’t mean private. Anyone who has the page’s address can read it, and the file is in your public repository. Only draft = true keeps a page off the site, and even then the file is in your repository.

Don’t leave a page with neither weight nor hidden = true at the top of content/. Zola leaves it out of the site, with only a warning, but still lists its address in sitemap.xml, so search engines would be sent to a missing page:

WARN  1 page(s) ignored (missing date or weight in a sorted section):
WARN  - /home/you/Desktop/myblog/content/colophon.md

So ./build, and the build on GitHub, stop with an error when that happens. Give the page a weight, or add hidden = true.

Bringing in notes from elsewhere

Notes written in another app can become posts, but each needs a few changes first, because Zola reads a file only in its own format. Work on copies, and keep the originals.

  1. Put a front matter block at the very top, with the title in it. Zola won’t build a file without one: it stops with Couldn't find front matter. It also doesn’t take the title from a # heading. A note that has its title only as a heading still builds, but it shows up on the home page as an empty numbered line, with no title in the browser tab or the feed. So move the heading’s text into title and delete the heading line:

    +++
    title = "The note's title"
    weight = 40
    date = 2026-09-26
    +++
    

    Give it the next free weight (see “Ordering posts”); a note without one is left out, and ./build stops.

  2. A block between two --- lines works too. Many note apps write their settings that way (it’s called YAML), and Zola reads it, with title: The note's title in place of title = "The note's title". Check its keys:

    • title, weight and date are needed, as above: title: ..., weight: 40, date: 2026-09-26.

    • tags: on a line of its own is ignored. Put the tags under taxonomies: instead:

      taxonomies:
        tags: [tea, travel]
      
    • Delete aliases:. In a note app an alias is another name for the note, but Zola treats each alias as an address and publishes a page there that sends readers on: aliases: [x] makes a page at /x/.

    • Other keys, such as created:, are ignored, so they can stay.

  3. Change the note app’s own links, embeds and pictures. Zola shows the [[...]] forms as plain text, and the others break on the site:

    In the noteWrite instead
    [[Other note]][Other note](@/other-note.md), naming the other post’s file
    [Other note](other-note.md) or (./other-note.md)[Other note](@/other-note.md): without @/ the link leads to “page not found”
    ![[photo.jpg]]![What the photo shows](@/attachments/photo.jpg), with the file added by ./attach
    ![photo](https://...), a picture from another siteThe same, after downloading the picture and adding it with ./attach: the security policy blocks pictures from other sites
    > [!note] and the lines under itAn <aside>, as in “Notes, fold-away sections, and wide items”
  4. Save it in content/ with a lowercase, dashed file name, which becomes its address, add its pictures with ./attach, and run ./build. If the build stops with Error when parsing front matter, see Troubleshooting.

Search

The Search button at the start of the menu opens a search box over the page you’re on. Type one or more words, and the pages that contain all of them are listed under the box, with pages whose titles match first. Under each title is a short piece of the page’s text around the first match, with your words in bold. Click a result to go to that page. Esc, or a click on the darkened page around the box, closes it and leaves you where you were.

How words are matched:

  • Each word you type must match the start of a word on the page. micro finds “microwave” and “Microscopes”, but wave doesn’t find “microwave”, and ear doesn’t find “search”.
  • Capital letters don’t matter, but accents do: CAFÉ finds “café”, and cafe doesn’t.
  • Symbols work: c++ finds “C++”.
  • In writing that uses vowel signs, such as Hindi or Tamil, a match can also start just after a vowel sign in the middle of a word.

How it works:

  • When the site is built, Zola writes a search index, search_index.en.json (the en is your SITE_LANGUAGE), holding the title and full text of every post and section.
  • static/search.js, which the script writes, is loaded by every page. The first time someone types in the box, it fetches the index from your site and searches it in the reader’s browser. There’s no search service and no code from anywhere else.
  • The box is a standard browser dialog: the page behind it is dimmed, the keyboard stays inside the box while it’s open, and when it closes the keyboard goes back to the Search button. Enter opens the first result, and a screen reader hears how many results there are, or “nothing matches”.
  • Results are shown as plain text, so code in a post, such as <img src=x>, appears in the results as those characters and is never run.
  • The Search button starts out hidden, and the script shows it. So a reader with JavaScript turned off sees no Search button, rather than a button that does nothing.
  • If the index fails to load, the box says search is unavailable: the index did not load, and the next letter typed tries again.

Posts and any sections are in the index. Hidden pages, the About page and other menu pages among them, aren’t, and in_search_index = true doesn’t bring one back. Nor are tag pages, or drafts (except in the preview). The home page is in the index too, but it’s left out of the results.

The index holds the full text of every post, and a reader’s browser downloads all of it the first time they search, so it grows with the blog. If it ever gets too big, search the script for index_format = "fuse_json", add truncate_content_length = 500 on a new line below it, and run the script again. That keeps only the first 500 characters of each page, so words further in can’t be found.

Search is the only script on the blog’s pages. While it’s on, every page’s security policy allows scripts from your own site (script-src 'self'), because the box can open on any page. On a yourname.github.io/notes address, “your own site” means everything at yourname.github.io, which includes any other GitHub Pages sites you have; with a custom domain it means just this blog. With ENABLE_SEARCH=false, the next run deletes static/search.js, the site is built without the index or the button, and every page’s policy goes back to script-src 'none', which blocks all scripts.

Email subscription

A static site can’t send email, but a newsletter service can do it for you. Readers sign up on the service’s own sign-up page, and the service emails them each new post it finds in your feed. Nothing on your site collects addresses.

To set it up:

  1. Make an account with a newsletter service that can email new posts from an RSS feed, often called RSS-to-email, such as Buttondown (see below), and create a list.
  2. Link to the service’s sign-up page for your list from the footer: add a link such as Newsletter=https://... to FOOTER_LINKS, with the page’s address in place of https://..., and run the script again. See “Footer text and links”.
  3. Give the service the address of your feed, as in “How a new post reaches subscribers”.

If you’d rather not use a service, your feed at /atom.xml already lets readers follow the blog, and it costs them nothing.

How a new post reaches subscribers

Nothing on your site sends email, and neither does pushing to GitHub. The service sends it, through its RSS-to-email feature. You give the service the address of your feed, https://yourname.github.io/notes/atom.xml (or the same on your own domain), and it checks the feed regularly and emails your subscribers when a new post appears in it.

That also keeps you from emailing people by accident. Each post appears in the feed once, identified by its address. Fixing a typo, pushing again or rebuilding the site doesn’t add anything new, so nobody gets an email. Two things to know: a post needs a date to be in the feed at all, so a post without one never reaches subscribers; and renaming a post’s file changes its address, which makes it look like a new post.

All of this needs GENERATE_FEEDS on. If you don’t want an RSS link on the site, set SHOW_RSS_LINK=false: the link goes, the feed stays, and email keeps working.

Buttondown is one service that does this. According to its documentation, as of September 2026:

  • It checks your feed every thirty minutes, so emails aren’t instant.
  • It can email each new post as it appears, or collect posts into a weekly or monthly email, or make each new post a draft that goes out only when you press send. Use drafts if you want to choose which posts are emailed.
  • When you first connect a feed that already has posts in it, turn on its “skip old items” setting. It then skips items dated more than a day before it found them, so subscribers don’t get your whole back catalogue at once. It goes on doing that for every new post, and a post’s date counts from midnight UTC, so give each post the day you publish it as its date: a post still carrying the day ./new started it can be skipped.
  • RSS-to-email is listed as a feature of its Basic plan, so check that your plan includes it.

Buttondown’s sign-up page is https://buttondown.com/yourusername, with your Buttondown username in place of yourusername, so the footer link is Newsletter=https://buttondown.com/yourusername. After setting it up, subscribe with an address of your own, to see what your readers will see.

Custom domain

Optional, and the only part that costs money: a domain name costs roughly 10 to 20 US dollars a year from a registrar, a company that sells domain names. HTTPS on it is still free; GitHub arranges the certificate for you.

  1. In the repository, go to Settings, then Pages, then Custom domain. Enter your domain (for example blog.example.com) and click Save. Do this before the next step: GitHub advises adding the domain here first, so nobody else can claim it.
  2. At your registrar, add the DNS records, the settings that tell browsers which server your domain lives on:
    • For a subdomain such as blog.example.com or www.example.com: a CNAME record pointing it at yourname.github.io.
    • For a bare domain such as example.com: four A records, pointing it at 185.199.108.153, 185.199.109.153, 185.199.110.153 and 185.199.111.153. If you want IPv6 too, add four AAAA records, pointing at 2606:50c0:8000::153, 2606:50c0:8001::153, 2606:50c0:8002::153 and 2606:50c0:8003::153. These are GitHub’s published addresses.
    • Don’t use a wildcard record such as *.example.com; GitHub warns that it lets others take over your subdomains.
  3. In the configuration block, change the existing CUSTOM_DOMAIN line to readonly CUSTOM_DOMAIN="https://blog.example.com", then save the script, run it again, commit, and push.
  4. When GitHub has the certificate ready, tick “Enforce HTTPS” in Settings, then Pages. DNS changes and the certificate can each take up to 24 hours, though usually it’s much quicker.

Step 3 is needed. Links, the stylesheet’s fonts, the favicon and the feed all use the site’s address, so without it your site loads at the new domain but still points readers back at github.io for all of those.

Set it in the script, not in config.toml, which is rewritten on every run. CUSTOM_DOMAIN changes the site’s address and nothing else; the “View history” and “Suggest an edit” links still point at the repository, where the files are.

You don’t need a CNAME file in the repository. GitHub’s documentation says that when a site is published by an Actions workflow, as this one is, a CNAME file isn’t created, and one that exists is ignored. The domain is kept in the repository’s Pages settings.

GitHub also recommends verifying your domain in your account’s Pages settings, which stops anyone else’s repository from using it.

Reference

The rest of this guide is for looking things up.

Running the script again

Running the script again is how every change to a setting is applied; there’s nothing else to learn. It finds the existing blog, rewrites the script-owned files from the current settings, and leaves content/, the rest of static/ and a README.md you’ve edited alone. It doesn’t make a new folder, and it only uses the network when it has to: to install Zola, if yours is outside the supported range, or to record a new Zola version in the workflow, including the first time you set GIT_REPO_URL.

Your own files, in content/ and static/, are written once, on the first run, and never touched again. Delete the example posts, replace avatar.svg, add fonts: nothing a re-run does will undo it.

Turning something off removes its file

Clearing a setting that produced a file removes the file, rather than leaving it behind:

  • FAVICON_TEXT="" removes the favicon the script drew.
  • ENABLE_SEARCH=false removes static/search.js.
  • An empty GIT_REPO_URL removes .github/workflows/deploy.yml, and the run tells you to commit the deletion. Do commit it. Otherwise the workflow stays in the repository and goes on publishing on every push, and since the site’s address has gone back to a placeholder (https://yourusername.github.io), what it publishes has links to an address that isn’t yours.

In each case the file is removed only if it’s exactly as the script last wrote it. A file you edited, or one the script has no record of writing, is left where it is. For the workflow, the run also warns you when it leaves it, because a leftover workflow goes on publishing.

The hand-edit check

If a script-owned file has been changed since the script last wrote it, a run stops before changing anything and names the file:

these files have been changed since this script last wrote them, and a re-run
would overwrite them:
  - templates/base.html

they are script-owned: change the matching render_* function or a configuration
variable rather than the file. to overwrite them anyway, run once with
    REGENERATE_TEMPLATES=true bash zola-blog-setup.sh
nothing has been changed.

This is so a change you made directly to a generated file isn’t lost without you noticing. You have two ways forward:

  • Keep the change: make it again in the script, in the render_ function that writes that file (here render_base_html), and then run once with REGENERATE_TEMPLATES=true bash zola-blog-setup.sh. To see what you changed, run git diff templates/base.html from inside the blog folder; it shows the change if you committed after the last run.
  • Throw the change away: run once with REGENERATE_TEMPLATES=true bash zola-blog-setup.sh.

The script compares each file with what the last run left, as recorded in .zola-blog-setup.manifest, not with what this run would write. So changing a setting never trips the check, even though it changes config.toml; only an edit made outside the script does. A blog without its .zola-blog-setup.manifest, because it was deleted, trips it for every script-owned file, and the message says so; one run with REGENERATE_TEMPLATES=true fixes that for good. If your computer has neither sha256sum nor shasum, the check can’t work, so it says so and lets the run carry on.

README.md is the exception. It’s the page GitHub shows for your repository, and nothing on the site is built from it, so editing it doesn’t stop a run, whether you edit it on your computer or on GitHub and then pull the change. The first run after the edit leaves the file as it is and says:

README.md was edited, so it's yours now: runs leave it alone (delete it to get the script's back)

From then on runs don’t touch it, so it no longer changes when GIT_REPO_URL or CUSTOM_DOMAIN does. To have the script write it again, delete it and run the script, or run once with REGENERATE_TEMPLATES=true.

Upgrading to a new version of the script

Your settings live in the script, so a new copy of the script starts with the default settings. Before running it, copy your settings across: every line you changed in the old script’s configuration block, and any changes you made inside render_ functions, such as colours in YOUR DESIGN. To find them, keep the old copy under another name, such as zola-blog-setup-old.sh, and run diff zola-blog-setup-old.sh zola-blog-setup.sh. It lists every line that differs, the new version’s own changes included, so look for the lines you recognise. If you skip this, the run applies the defaults: the title goes back to “Myblog”, and with an empty GIT_REPO_URL it removes your workflow.

Then run the new script against the blog’s folder, with the same PROJECT_DIR and BLOG_NAME. It rewrites the script-owned files, and stops first if you edited one of them by hand; see “The hand-edit check”.

This guide covers upgrading a blog made with v100 or v101, which make the same pages. A blog made with v28 to v35, or with v96 to v99, upgrades the same way, since nothing those versions rely on has been removed, but what the versions since then changed is listed in the guides that came with them. The numbers jump from v35 to v96 because they now also count the versions from before the script’s numbering was restarted: v96 is the version that came after v35. For a blog made with an older version, run v28’s script against it first, and follow the notes in v28’s guide for the version the blog was made with.

What changes:

  • An edited README.md no longer stops a run. Before, editing it stopped every run at the hand-edit check, like an edit to any script-owned file. Now the run keeps your README, says once that it’s yours, and carries on; see “The hand-edit check”. If the old version stopped on README.md for you, just run this one.
  • An unedited README.md gets one new sentence, saying it becomes yours once you edit it, so the first run changes it. Commit it with the rest.

Looking at one generated file

You can see what the script would write for one file without touching the blog, because loading the script with source doesn’t run it. From the folder holding the script:

bash -c 'source zola-blog-setup.sh; render_base_html' | less

Press q to leave less. render_page_html, render_components, render_site_config, render_readme and the other render_ functions work the same way. Some of them use values the script normally works out first, and print empty ones when run this way; render_site_config is one. The # Vars: note above each function says what it uses. render_base_html uses nothing, so it’s always safe.

If you write a file out this way into the blog, the manifest no longer matches it, so the next run treats it as edited by hand, as it should; see “The hand-edit check”.

Moving to a newer Zola

From the folder holding the script:

bash zola-blog-setup.sh update-zola

This installs the newest Zola release, as long as it’s a 0.23 release, prints its checksum, and reminds you that blogs keep the version their workflow names. It ends like this:

zola is now at 0.23.6.
existing blogs keep the version their workflow pins. re-run zola-blog-setup
against a blog to re-pin it to this version, then commit and push.

So after updating, run the setup script again for each blog, then commit and push, to move that blog’s workflow to the new version. If your Zola is already the newest release, it says zola 0.23.6 is already the latest (0.23.6). and does nothing.

What the checksum proves

Zola doesn’t publish checksums alongside its downloads, so there’s nothing independent to compare a download with when the script installs Zola. The script therefore prints the checksum of what it downloaded, rather than checking it against anything. You can compare it with someone else’s copy, or one from another computer. What it rests on is the secure (HTTPS) connection to GitHub, the same thing that got you the script.

The workflow’s checksum is a real check. When you first set up publishing, the script downloads the Linux copy of Zola once, works out its checksum, and writes that number into deploy.yml. Every build on GitHub then checks its download against it before using it. A release’s version number names a file, not its contents; if the file behind that name were ever swapped, the build would fail instead of publishing. There is no way for the workflow to skip the check.

Publishing and permissions

The workflow runs on every push to main (or master), and when you start it by hand from the Actions tab. It has two parts, called jobs:

  • build gets the files and Zola, checks Zola’s checksum, and builds the site. It can read the repository but not change it, and it doesn’t keep git’s access key once it has the files, because it runs code from your repository.
  • deploy publishes the built site. It’s the only part with permission to publish (pages: write and id-token: write, which GitHub’s own publishing action needs). It never changes the repository.

Everything the workflow depends on is fixed: the three GitHub actions it uses (by their commit code, a string of 40 letters and digits), the kind of machine it runs on (ubuntu-24.04, rather than whichever Ubuntu GitHub currently calls the latest), Zola’s version, and Zola’s checksum. That makes it much less likely that a change somewhere else breaks a build that worked before. The actions are actions/checkout v7.0.1, actions/upload-pages-artifact v5.0.0 and actions/deploy-pages v5.0.1, which run on Node 24. GitHub set 23 September 2026 as the date its build machines drop the older Node 20.

To add a licence to the repository, do it after the first push, so that it arrives as an ordinary commit. Then, before you next push, bring that commit to your computer: from inside the blog folder, run git pull --rebase. It prints Fast-forward and the licence’s file name, or, if you’ve committed since, Successfully rebased and updated refs/heads/main. Plain git pull refuses in that second case, with You have divergent branches.

The security policy

Every published page tells the reader’s browser what it may load, with a security policy (a Content-Security-Policy, in a <meta> tag in templates/base.html):

default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'none'

It’s one line in the page; here it’s split over two. In plain words:

  • default-src 'self': anything the other rules don’t cover, such as fonts, videos, frames and the search index, comes only from your own site.
  • script-src 'self': scripts only from your own site, and only while search is on. With search off it’s script-src 'none': no scripts at all.
  • style-src 'self' 'unsafe-inline': styles may be written into the page itself. The code highlighter writes a style attribute onto every coloured part of every code example, and the policy has to allow that. So moving the stylesheet into a separate file wouldn’t make the policy any stricter.
  • img-src 'self' data:: pictures only from your own site, or written into the page itself. A picture from another site is blocked before the browser asks for it, so no other company ever sees your readers’ addresses.
  • form-action 'none': no form on the site can send what readers type anywhere. It has to be spelled out, because unlike most rules it has no default.
  • object-src 'none' and base-uri 'self' block two old ways of running outside code.

There’s no frame-ancestors, the rule that would stop other sites showing yours inside a frame. Browsers ignore it in a <meta> tag, and GitHub Pages doesn’t let you set the server headers where it would work, so on this host there’s no way to add that protection.

Embedding a YouTube video, a picture from another site, or a comments service means loosening this policy, and loosening it lets whoever you embedded see your readers. Decide that deliberately.

The preview gets the same policy, with one difference: it also allows scripts written into the page, because Zola’s preview adds two scripts of its own, which reload the page when you save. So a picture or video from another site fails in the preview, just as it will on the published site, instead of only after you publish. The template tells the two apart by the address: the preview’s starts with http://, and a published site’s always starts with https://, because the script only accepts https:// addresses for GIT_REPO_URL and CUSTOM_DOMAIN. Open the preview at the address zola serve prints; at any other address, the policy counts the preview’s own fonts and pictures as coming from somewhere else and blocks them.

To see the policy at work, open your browser’s developer tools (F12), then the Console tab. The policy reports there anything it blocks, and a site you haven’t changed should show nothing from it. Until you add Charter (see “Fonts”), the console also lists the font files as not found (404), which is expected. Serving public/ from your own computer doesn’t work as a test: every link in it points at your real address, so the policy blocks the fonts, the favicon and the search script, and the page looks broken.

What’s private, and what isn’t

  • Your repository is public, on a free account. Everything you’ve committed, every earlier version of it, drafts included, can be read by anyone.
  • Commits show a name and an email. Use GitHub’s private address (see “Your first blog”, step 1).
  • Photos carry hidden location and camera data until you remove it, and the original file is published. See “Your photo”.
  • Hidden pages are public. hidden = true keeps a page off the lists, the feed, search and the sitemap, but anyone with its address can read it, and its file is in your repository. Only a draft stays off the site.
  • HTML comments aren’t hidden. A note written in a post as <!-- ... --> doesn’t show on the page, but it’s in your repository, and in the feed, atom.xml, which carries the full text of every post with a date.
  • Nothing on the site loads from other companies, and the security policy enforces that.
  • GitHub sees your readers. GitHub Pages logs visitors’ IP addresses, whatever the site does. The policy above keeps other companies out; it can’t do anything about the host itself.
  • No referrer. Pages tell the browser not to pass on which page a reader came from when they follow a link to another site.

Troubleshooting

Running the script

unknown command: ... Everything is set in the configuration block at the top of the script. The only words the script accepts after its name are help and update-zola.

run this script with bash: bash zola-blog-setup.sh. You ran it with sh (or with zsh), which can’t run it. Type bash in front, as in bash zola-blog-setup.sh.

bash: zola-blog-setup.sh: No such file or directory. You’re not in the folder where you saved the script. Run cd ~/Downloads (or wherever it is) first, or give its full path: bash ~/Downloads/zola-blog-setup.sh.

line 82: GIT_REPO_URL: readonly variable, or the same about another setting. The configuration block has two lines for that setting, because a line was added instead of the existing one being changed. Delete one of them.

line 89: $5: unbound variable, or another line number and name. A setting contains a $ without a backslash in front of it. Write \$, and likewise \" and \`.

line 117: FOOTER_TEXT isn't quoted as the script needs, or the same about another setting. A " or ` inside the value has no backslash before it, or the value’s closing " is missing. Put a backslash before each " or ` that should be shown, as in readonly FOOTER_TEXT="Motto: \"ora et labora\""; a doubled "" doesn’t work. Nothing was changed.

SHOW_TOC must be true or false (unquoted), got: 'yes', or a similar message about another setting. true and false go without quotes. Messages about MASTHEAD, BLOG_NAME, GIT_REPO_URL, CUSTOM_DOMAIN, MENU_PAGES, FOOTER_LINKS or DATE_FORMAT say what’s allowed.

MENU_PAGES names now, but there's no content/now.md. Make the page first, as in “Adding a page to the menu”, step 1, or take the name out of MENU_PAGES. Check the spelling: the name must match the file’s, without .md. Nothing was changed.

MENU_PAGES names now, but content/now.md doesn't say hidden = true. Add hidden = true on the line after the page’s title. Without it the page counts as a post; see “Adding a page to the menu”. Nothing was changed.

MENU_PAGES names now, but there's both content/now.md and content/now/_index.md. Keep one: the file for a single page, or the folder for a page with pages under it. Nothing was changed.

still the examples the script came with, and GIT_REPO_URL will publish them: .... A reminder, not an error: the run finished. The settings it names, or the About page, still have the values the script came with. Change them before you push; see “Before you publish”.

missing required tools: followed by a list. Install what it names. On Debian, Devuan or Ubuntu, sudo apt install curl tar covers both of the usual ones.

not a git repo; remote not updated, or git init failed - run manually later. The blog folder has no git repository of its own, usually because git wasn’t installed when the blog was made. Make one: from inside the blog folder, run git init -b main, which prints Initialized empty Git repository in and the folder’s path. Then, once git knows your name and email (see “Your first blog”, step 1), run git add -A and git commit -m "First commit", and run the script again. With GIT_REPO_URL set, it prints added remote 'origin': and the repository’s address.

'...' exists but holds no config.toml; move it aside or point BLOG_NAME somewhere else. A folder with that name is already there, and it isn’t a blog made by this script, so the script won’t write into it. Rename that folder, or choose another BLOG_NAME.

The blog appeared somewhere unexpected, or a second blog appeared. PROJECT_DIR is your Desktop unless you change it, whatever folder you run the script from. And changing PROJECT_DIR or BLOG_NAME after the blog exists makes a new blog in the new place; the old one stays where it was.

The run stops and lists files it would overwrite. That’s the hand-edit check; see “The hand-edit check”. Either .zola-blog-setup.manifest is missing (one run with REGENERATE_TEMPLATES=true fixes it), or a script-owned file was edited directly.

zola 0.24.0 is the latest release, but this script supports >= 0.23 and < 0.24. A newer Zola is out than the script’s templates were written for. Nothing was changed. Use a newer version of the script if there is one, or name a 0.23 release for this run: find the newest release whose name starts with v0.23. on github.com/getzola/zola/releases, and run ZOLA_VERSION_OVERRIDE=<that name> bash zola-blog-setup.sh.

could not resolve the latest zola release from GitHub (network?) The script couldn’t reach GitHub to find the newest Zola. Check your internet connection and run it again.

could not download zola .... The download of Zola itself failed. Check your internet connection, and, if you named a release with ZOLA_VERSION_OVERRIDE, that the name is exactly as it appears on github.com/getzola/zola/releases, such as v0.23.6. Nothing was changed.

could not hash the linux zola ... build, which the workflow pins. The script couldn’t download the Linux copy of Zola to work out its checksum. On a first run, it’s usually the network, or a release name that doesn’t exist. A re-run with the same Zola version doesn’t need the download at all, so seeing this on a re-run means the version changed.

Zola not found after installing: zola: command not found. The script puts Zola in ~/.local/bin, and your terminal doesn’t look there yet. On Debian, Devuan and Ubuntu, logging out and back in usually fixes it, because the standard ~/.profile adds ~/.local/bin once that folder exists. Otherwise, add this line to ~/.profile, then log out and back in: ~/.profile is read when you log in, not by each new terminal. On a Mac, add it to ~/.zprofile instead, and open a new terminal:

export PATH="$HOME/.local/bin:$PATH"

Two Zolas. If Zola was already installed some other way, for example with Homebrew or Snap, the one in ~/.local/bin is a second copy, and the order of folders in your PATH decides which runs. That’s how a site ends up building on your computer but failing on GitHub, or the other way round. command -v zola shows which one runs. Keep one: remove the other, or remove ~/.local/bin/zola and keep yours inside the supported range.

Previewing and building

./build fails with “the page(s) named above produced no HTML”. A page at the top of content/, or a child page whose parent sorts by weight, has no weight, so Zola left it out while still listing its address in the sitemap. Give it a weight, or add hidden = true if it shouldn’t be listed, as a page in the menu shouldn’t; see “Pages that aren’t posts”.

Page `now.md` not found or Section `projects/_index.md` not found, pointing at base.html. A page in the menu was moved, renamed or deleted after the script last ran, and the menu still points at its old place. Run the script again, which finds where the page is now. If the page is gone, take its name out of MENU_PAGES first.

Broken relative link `@/...`, or Found 1 broken internal anchor link(s). An @/ link names a file or heading that doesn’t exist. Fix the link; the check is there to stop broken links reaching your site. Deleting second-post.md but keeping hello-world.md, which links to it, causes this too. For a picture, @/attachments/..., check that the file is in content/attachments/ with exactly that name (capital letters count), and that content/attachments/_index.md is there and doesn’t say render = false.

Variable `...` is not defined, or Closing comment tag `#}` not found, in a post. The post contains {{, {% or {#, which Zola reads as template code. Wrap that part in {% raw %} and {% endraw %}; see “Code examples”.

`resize_image`: Cannot find file: me.jpg. The photo’s path counts from the content folder, not from the page’s own folder. Write attachments/me.jpg, not me.jpg.

`get_url`: could not resolve URL for link `@/attachments/...` not found. A video line names a file that isn’t in content/attachments/, or spells it differently. The path in src starts from the content folder, without @/: src="attachments/tea-bus.mp4".

A link to one of your own pages leads to “page not found”. It’s written as a path to a .md file, such as [child A](./child-a.md). Zola doesn’t turn that into the page’s address, so the link points at a file that isn’t on the site. Write it with @/ and the path from the content folder: [child A](@/test/child-a.md). See “Links”.

unknown field `date`, about an _index.md. A section’s front matter, in an _index.md, doesn’t take a date. Delete that line. Its children are the pages that have dates; see “Child pages”.

A parent page doesn’t list its children. Its _index.md says hidden = true, which the children take on: delete that line. Or the file is index.md, without the underscore, which makes it an ordinary page: rename it _index.md. If the parent’s own address is “page not found” while its children’s pages work, the folder has no _index.md at all. See “Child pages”.

In the preview, the fonts, pictures or favicon are missing, and the browser’s console says Refused to load. The preview is open at an address other than the one zola serve printed, such as your computer’s network address when it printed 127.0.0.1. The security policy then counts the preview’s own files as coming from elsewhere. Open the address it printed. To preview on another device, start the preview from inside the blog folder with zola serve --drafts -i 0.0.0.0 -u and your computer’s network address, such as zola serve --drafts -i 0.0.0.0 -u 192.168.1.5, and open the address it prints, http://192.168.1.5:1111, on every device, this computer included. While it runs, anyone on the same network can open it too, drafts included, so leave out --drafts unless you trust the network, and stop it with Ctrl+C when you’re done.

Couldn't find front matter, naming a file. A file in content/ has no front matter block at its top. Give it one; see “Bringing in notes from elsewhere”.

Theme `...` does not exist. LIGHT_CODE_THEME or DARK_CODE_THEME isn’t a name from Zola’s list; see “Settings, one by one”.

unknown field `...` about config.toml. A line was added in the wrong place in render_site_config, or directly to config.toml. Zola rejects settings it doesn’t know.

TOML parse error at line .... A line you added in render_site_config in the script, such as skip_content_templating, has a slip: a missing quote or comma, curly quotes, or a line in the wrong place. The line number is in config.toml, which the script writes; fix the same line in the script and run it again.

Error when parsing front matter of page, naming a post or page. The block between its +++ lines has a slip, and Zola doesn’t say which. The usual ones are quotes around a number (weight = "30" instead of weight = 30), a missing or curly quote, a line written below [taxonomies] that isn’t tags, and a second [extra] line.

A post’s tags don’t show, and there’s no error. Its tags line isn’t under [taxonomies]. Zola ignores a tags line anywhere else, without a message. In a block between --- lines, tags: goes indented under taxonomies:; see “Bringing in notes from elsewhere”.

./serve: Permission denied. The file lost its permission to run, usually by being copied or archived. From inside the blog folder, run chmod +x serve build new attach.

Markdown inside <aside> or <details> shows up as asterisks and brackets. It needs a blank line after the opening tag and another before the closing one; see “Notes, fold-away sections, and wide items”.

Numbered sub-items come out joined onto one line, or as the next number instead of a sub-item. Number each level the ordinary way, 1., 2., not 1.1, and indent each level four spaces or one tab; see “Numbered outlines”.

A paragraph turned into a heading. A line of --- directly under a line of text makes that text a heading. Leave a blank line above the ---.

Appearance

Some links in a post are blue, and others aren’t. The blue ones have been followed: the browser has their addresses in its history. A link that hasn’t been followed takes the colour of the text around it, underlined; see --link in “Colours, sizes and typefaces”. Chrome counts a link to another site as followed only if it was followed from your own site, so an address visited some other way stays the colour of the text.

The favicon shows the wrong letter. With "auto", it’s the first letter or digit of SITE_TITLE, not of BLOG_NAME, shown as a capital.

The top left shows an empty space. MASTHEAD="image", and static/logo.svg is missing.

The logo is a solid block. The logo is used as a stencil, so a background in logo.svg fills the whole box. Use a flat shape on a transparent background.

Dark mode looks wrong on your computer but right on others. Your desktop may not be telling the browser whether you prefer dark; see “Dark by default”. Test with the browser’s developer tools.

The About page’s placeholder picture is a pale square on a dark page, or a dark one on a light page. static/avatar.svg was drawn for a different LIGHT_THEME setting. It’s yours, not script-owned, so re-running won’t redraw it; replace it with your photo (see “Your photo”), or edit its colours.

A --- meant as a dividing line sits right under a heading. A --- straight after a heading underlines that heading. To divide the page there, put the --- above the heading instead.

Code examples ignore the colours in YOUR DESIGN. They always will: the highlighter writes each example’s colours into the example itself, and those win over the stylesheet. Change LIGHT_CODE_THEME and DARK_CODE_THEME instead.

A picture in a post shows in the preview but not on the published site. Its path starts with /, which on a yourname.github.io/notes address points outside your blog. Put the picture in content/attachments/ and refer to it with @/attachments/; see “Pictures, GIFs and video”.

A picture from another site doesn’t show, in the preview or on the published site. The security policy only lets pictures come from your own site; see “The security policy”. The preview blocks them too, so you find out before publishing. Download the picture, add it with ./attach, and link to it with @/attachments/.

A photo shows on its side. The tag that says which way up it goes was removed along with its other hidden data. Rotate the picture itself in an image editor and save it, or clean the original again with the command in “Your photo”, which keeps that tag.

Getting it online

Permission denied (publickey) from ssh -T or git push. GitHub doesn’t have your public key, or ssh is offering a different key. Check that ~/.ssh/id_ed25519.pub exists and that the same line is in your GitHub SSH keys. ssh -vT git@github.com shows which key ssh tried.

git push asks for a username and password. The link to GitHub uses an https:// address instead of SSH. GitHub doesn’t accept account passwords for git; switch it to SSH, with your username and repository name in place of these:

git remote set-url origin git@github.com:yourname/notes.git

error: src refspec main does not match any. There are no commits yet, usually because git didn’t know your name and email during the first run. Set them (see “Your first blog”, step 1), then run git add -A and git commit -m "First commit", and push again.

The push is rejected, with a hint beginning “Updates were rejected because the remote contains work”. On the first push, the repository wasn’t empty: something such as a README or licence was added when it was created. If it has nothing else in it, the simplest fix is to delete the repository on GitHub (at the bottom of its Settings page), create it again with nothing added, and push again. On a later push, something was committed on GitHub since your last one, such as a licence: from inside the blog folder, run git pull --rebase, then git push; see “Publishing and permissions”.

The workflow fails at its deploy step, with Ensure GitHub Pages has been enabled. Pages isn’t switched on. In Settings, then Pages, set Source to “GitHub Actions”, then run the workflow again from the Actions tab.

The published address shows your README, or something that isn’t your blog. Settings, then Pages, then Source is set to “Deploy from a branch”, so GitHub is building the repository its own way instead of using your workflow. Set it to “GitHub Actions” and run the workflow again.

The site is up, but links go to the wrong place, or the fonts or favicon are missing. The site’s address doesn’t match where it’s published. The script works the address out from GIT_REPO_URL (and CUSTOM_DOMAIN), so check those, especially after renaming the repository, then re-run, commit and push.

CUSTOM_DOMAIN had no effect. Check that the run finished (the hand-edit check stops a run before anything is written), that you set it in the script and not in config.toml, and that you committed and pushed afterwards.

The build on GitHub uses a different Zola from your computer. The workflow keeps the version it named when the script last ran against this blog. If you’ve changed Zola since, with update-zola or another way, run the script again, then commit and push, to move the workflow to it. It also differs when your Zola isn’t a published release, which the run says, and when another Zola comes first in your PATH (see “Two Zolas”). The version named in the workflow is the one that counts for the published site.

The build on GitHub fails with zola.tar.gz: FAILED and WARNING: 1 computed checksum did NOT match. The Zola file GitHub downloaded isn’t the one whose checksum was recorded. That can mean the file behind that release was replaced, and that’s exactly what the check is for, so don’t just record a new checksum. Find out why first: look at Zola’s release page and announcements, and, from inside the blog folder, run git log -p .github/workflows/deploy.yml, which lists every change made to the workflow, the checksum line included. Once you’re satisfied the new file is genuine, delete .github/workflows/deploy.yml and run the script again: it downloads Zola, records the checksum afresh, and writes the workflow back. Then commit and push.

Search

There’s no Search button. ENABLE_SEARCH is false, or JavaScript is turned off in that browser; the script is what shows the button.

Search doesn’t find a word that’s on the page. Words have to be typed from their start: wave doesn’t find “microwave”. Accents count too: cafe doesn’t find “café”.

The search box says “search is unavailable: the index did not load”. The page was built with search on, but the index file isn’t there. Build again with ./build, or push to rebuild the published site. If it was a network hiccup, typing another letter tries again.

Command reference

From the folder holding the script:

CommandWhat it does
bash zola-blog-setup.shCreates the blog at PROJECT_DIR/BLOG_NAME, or updates it if it’s already there, from the configuration block.
bash zola-blog-setup.sh update-zolaInstalls the newest Zola release, if it’s inside the supported range.
bash zola-blog-setup.sh helpLists the commands. -h and --help work too.

Everything else is a setting in the configuration block; see “Settings, one by one”.

Settings for a single run, put in front of the command:

SettingWhat it does
START_PREVIEW=falseDon’t start the preview at the end of this run.
REGENERATE_TEMPLATES=trueOverwrite script-owned files that were edited by hand, and a README.md you’ve edited.
ZOLA_VERSION_OVERRIDE=<release>Use this Zola release (its name on github.com/getzola/zola/releases, such as v0.23.6) wherever the script would otherwise look up the newest one.

Inside the blog folder:

CommandWhat it does
./serveThe preview, at http://127.0.0.1:1111, updating as you save, with drafts marked “(draft)”. Ctrl+C stops it.
./buildBuilds the finished site into public/, without drafts, and stops with a message if something is wrong that would also stop GitHub’s build.
./new name-of-postStarts content/name-of-post.md, a draft at the end of the list, with its front matter filled in and a title made from the name.
./attach path/to/file new-nameCopies a picture or video into content/attachments/ without its hidden data, and prints the line to put in a post. Not for SVG drawings; see “Pictures, GIFs and video”.
git statusShows what has changed since your last commit.
git add -A then git commit -m "..."Saves a snapshot of every change.
git pushSends your commits to GitHub, which then publishes the site.

Other computers and hosts

On a Mac, the script installs Zola into ~/.local/bin. On Windows, in Git Bash, it installs it into ~/bin, and needs unzip rather than tar. There’s no Zola for Windows on ARM, so there the script stops and asks you to install Zola yourself, then run it again. The workflow always runs on GitHub’s Linux machines, whatever computer you use.

To publish somewhere other than GitHub Pages, the finished site is just the public/ folder that ./build makes, and any host for static sites can serve it. What you’d give up is the workflow, with its fixed versions and checksum check, which you’d have to rebuild for that host.

Bootstrap-zola script

The script below scaffolds a Zola blog, installs and version-pins the Zola binary, writes a first-party GitHub Pages deploy workflow, and opens a live preview. For details, see Publish a blog.

#!/usr/bin/env bash
# zola-blog-setup, v102
#
# Bootstraps a Zola 0.23 blog for GitHub Pages at PROJECT_DIR/BLOG_NAME, and
# keeps it in step with the configuration block below. Re-running is the update
# path: the script-owned files are regenerated, content/ is never touched.
#
#   bash zola-blog-setup.sh              # create or update the blog
#   bash zola-blog-setup.sh update-zola  # update the local Zola
#   bash zola-blog-setup.sh help
#
# How to use it: zola-blog-guide.md. The comments here are for reviewers.

# Under sh (dash) or zsh, set -o pipefail below fails with a cryptic error.
# POSIX, so any shell can run it.
if [ -z "${BASH_VERSION:-}" ]; then
    echo "run this script with bash: bash zola-blog-setup.sh" >&2
    exit 2
fi

set -euo pipefail

## ─── helpers ──────────────────────────────────────────────────────────
say()        { printf '%s\n' "$*"; }
warn()       { printf '%s\n' "$*" >&2; }
die()        { printf '%s\n' "$*" >&2; exit 1; }
die_usage()  { printf '%s\n' "$*" >&2; exit 2; }

# Escape a value for a double-quoted TOML basic string (backslash first).
toml_escape() {
    local s="$1"
    s="${s//\\/\\\\}"
    s="${s//\"/\\\"}"
    printf '%s' "$s"
}

# Escape for XML text. & goes first, so the ampersands later rules add survive;
# each & in a replacement is backslashed, because from bash 5.2 an unquoted &
# there expands to the match: ${s//</&lt;} would give "<lt;".
esc_xml() {
    local s="$1"
    s="${s//&/\&amp;}"
    s="${s//</\&lt;}"
    s="${s//>/\&gt;}"
    printf '%s' "$s"
}

# Guard: run from a saved file, not piped from curl.
if [ ! -f "${BASH_SOURCE[0]}" ] || [ ! -r "${BASH_SOURCE[0]}" ]; then
    die "run zola-blog-setup from its saved file, not piped from curl."
fi

# Guard: the settings below are checked as text before bash runs them. In a
# value, a " or ` without a backslash before it misleads bash: a " ends the
# quotes early, so it vanishes, the value stops at the next space, or the run
# fails at some line below; a ` runs the text up to the next one as a command.
# None of that names the setting; this does.
check_setting_quotes() {
    local line name value n=0 inside=false
    local setting='^[[:space:]]*(readonly[[:space:]]+)?([A-Z_][A-Z0-9_]*)="(.*)$'
    local quoted='^(\\.|[^\\"`])*"([[:space:]]+#.*)?[[:space:]]*$'
    while IFS= read -r line; do
        n=$((n + 1))
        if [[ $line == "## ─── user configuration"* ]]; then inside=true; continue; fi
        [ "$inside" = true ] || continue
        [[ $line == "## ───"* ]] && return 0
        [[ $line =~ $setting ]] || continue
        name=${BASH_REMATCH[2]} value=${BASH_REMATCH[3]}
        [[ $value =~ $quoted ]] || die "line $n: $name isn't quoted as the script needs: the value goes between two \", with a backslash before any \" or \` inside it, as in \"say \\\"hi\\\"\". nothing has been changed."
    done < "${BASH_SOURCE[0]}"
}
check_setting_quotes

## ─── user configuration ───────────────────────────────────────────────
# The script's only input: it takes no arguments, and a re-run applies whatever
# has changed here. Each setting: zola-blog-guide.md, "Settings, one by one".

PROJECT_DIR="$HOME/Desktop"       # the folder the blog's folder goes in; ~ allowed
readonly BLOG_NAME="myblog"       # the blog's own folder; readers see SITE_TITLE

readonly GIT_REPO_URL=""          # https://github.com/user/repo; "" = local only
readonly CUSTOM_DOMAIN=""         # https://yourdomain.com; changes base_url only

# Usually set for one run, in front of the command.
readonly START_PREVIEW="${START_PREVIEW:-true}"
readonly REGENERATE_TEMPLATES="${REGENERATE_TEMPLATES:-false}"

readonly SITE_TITLE="Myblog"
readonly SITE_DESCRIPTION="one-line description"   # "" for none
readonly SITE_AUTHOR="your name"                   # the feed needs one
readonly SITE_LANGUAGE="en"

readonly MASTHEAD="text"          # "text", "none", or "image" (static/logo.svg)
readonly MENU_PAGES="about"       # pages in content/, by name, in menu order
readonly FAVICON_TEXT="auto"      # "auto", up to 3 characters, or "" for your own
readonly LIGHT_THEME=false        # true: light for readers whose OS asks for it

readonly LIGHT_CODE_THEME="github-light"   # Giallo theme names
readonly DARK_CODE_THEME="github-dark"

readonly GENERATE_FEEDS=true      # atom.xml, and one per tag
readonly SHOW_RSS_LINK=true       # the menu link only; the feed stays
readonly ENABLE_TAGS=true
readonly ENABLE_SEARCH=true       # the only script on the pages

readonly SHOW_TOC=true
readonly SHOW_BREADCRUMBS=true    # Home and a page's folders, over its title
readonly SHOW_READING_TIME=true   # "N min read" on the date line
readonly DATE_FORMAT="%Y-%m-%d"   # "%-d %B %Y" gives 21 September 2026
readonly SHOW_HISTORY_LINK=false  # needs GIT_REPO_URL
readonly SHOW_SUGGEST_EDIT=false  # needs GIT_REPO_URL

# The footer: links as Label=address pairs, space-separated, one-word labels
# (delete a pair to drop that link), then a line of small print under them.
# "" for none.
readonly FOOTER_LINKS="X=https://x.com/yourhandle Nostr=https://nostr.com/npub1yourkeyhere GitHub=https://github.com/yourhandle/yourrepo"
readonly FOOTER_TEXT=""           # such as "© 2026 Your Name"

## ─── derived from the block above (do not edit) ───────────────────────
# Expand a leading ~ (bash doesn't, inside quotes) and make the path absolute.
PROJECT_DIR="${PROJECT_DIR/#\~/$HOME}"
[ "${PROJECT_DIR:0:1}" = "/" ] || PROJECT_DIR="$PWD/$PROJECT_DIR"
PROJECT_DIR="${PROJECT_DIR%/}"
readonly PROJECT_DIR
readonly BLOG_DIR="$PROJECT_DIR/$BLOG_NAME"

# FAVICON_TEXT -> the mark drawn. "auto" takes SITE_TITLE's first A-Z or 0-9, so
# a renamed site can't keep a stale letter; a set value is cut to 3 characters.
_favicon_mark=""
if [ "$FAVICON_TEXT" = "auto" ]; then
    _favicon_mark="${SITE_TITLE//[^A-Za-z0-9]/}"
    _favicon_mark="${_favicon_mark:0:1}"
    _favicon_mark=$(printf '%s' "$_favicon_mark" | tr '[:lower:]' '[:upper:]')
elif [ -n "$FAVICON_TEXT" ]; then
    _favicon_mark="${FAVICON_TEXT:0:3}"
    if [ "${#FAVICON_TEXT}" -gt 3 ]; then
        warn "FAVICON_TEXT is ${#FAVICON_TEXT} characters and a favicon is read at 16px; only \"${_favicon_mark}\" is drawn"
    fi
fi
readonly FAVICON_MARK="$_favicon_mark"
unset _favicon_mark

## ─── pinned tool versions ─────────────────────────────────────────────
# A band, not a floor, applied to the local Zola and to the workflow pin alike.
# Floor 0.23: the templates are Tera v2 (components, keyword tests, strict
# undefined, no %+ date). Ceiling 0.24: refuse an untested template language
# rather than report success and fail at build. Raise it only after porting the
# templates.
readonly ZOLA_MIN_VERSION="0.23"
readonly ZOLA_MAX_VERSION="0.24"   # exclusive: 0.23.x is supported, 0.24 is not

## ─── shared state (set per invocation) ────────────────────────────────
OS=""
ARCH=""
TMP_ZOLA=""
GITHUB_REPO=""
IS_UPDATE=""
ZOLA_VERSION=""
CI_ZOLA_VERSION=""
ZOLA_SHA256=""
BLOG_CREATED=""
BASE_URL=""
SOURCE_URL=""
HISTORY_URL=""
HISTORY_HINT=""
FORGE_URL=""
REMOTE_URL=""
TODAY=""
YESTERDAY=""
TWO_DAYS_AGO=""
README_KEPT=""

cleanup_tmp() { [ -n "$TMP_ZOLA" ] && rm -rf "$TMP_ZOLA"; return 0; }

# Removes only a folder this run created: BLOG_CREATED is set right after the
# mkdir and cleared once the blog is complete.
cleanup_partial_blog() {
    [ -n "$BLOG_CREATED" ] && [ -d "$BLOG_CREATED" ] && rm -rf "$BLOG_CREATED"
    return 0
}
clear_blog_cleanup() { BLOG_CREATED=""; return 0; }
cleanup_all() { cleanup_tmp; cleanup_partial_blog; }
trap cleanup_all EXIT

# =============================================================================
# Dispatch
# =============================================================================
# Two subcommands. Anything else is an error, never a folder named after a typo.
main() {
    case "${1:-}" in
        "")              cmd_setup ;;
        help|-h|--help)  cmd_help ;;
        update-zola)     cmd_update_zola ;;
        *)               die_usage "unknown command: $*. the script takes help or update-zola, or nothing; everything else is a setting in the block at the top (bash zola-blog-setup.sh help)" ;;
    esac
}

# =============================================================================
# help
# =============================================================================
cmd_help() {
    say "zola-blog-setup — bootstraps a minimal Zola blog."
    cat <<'EOF'

  bash zola-blog-setup.sh              create or update PROJECT_DIR/BLOG_NAME
                                       from the configuration block
  bash zola-blog-setup.sh update-zola  install the latest supported Zola
  bash zola-blog-setup.sh help         this list (also -h and --help)

For one run, in front of the command:
  START_PREVIEW=false          skip the live preview
  REGENERATE_TEMPLATES=true    overwrite script-owned files edited by hand
  ZOLA_VERSION_OVERRIDE=<tag>  use this Zola release instead of the latest

Everything else is a setting in the configuration block. Guide:
zola-blog-guide.md.
EOF
}

# =============================================================================
# Platform detection
# =============================================================================
detect_platform() {
    OS=$(detect_os)
    ARCH=$(detect_arch)
    [ "$OS" = "unknown" ] && die "unsupported OS (uname -s: $(uname -s 2>/dev/null))"
    [ "$ARCH" = "unknown" ] && die "unsupported architecture (uname -m: $(uname -m 2>/dev/null))"
    return 0
}

detect_os() {
    case "$(uname -s 2>/dev/null || echo unknown)" in
        Linux*)               echo "linux" ;;
        Darwin*)              echo "macos" ;;
        MINGW*|CYGWIN*|MSYS*) echo "windows" ;;
        *)                    echo "unknown" ;;
    esac
}

detect_arch() {
    case "$(uname -m 2>/dev/null || echo unknown)" in
        x86_64|amd64)  echo "x86_64" ;;
        aarch64|arm64) echo "aarch64" ;;
        *)             echo "unknown" ;;
    esac
}

# =============================================================================
# setup — create the blog, or update it in place
# =============================================================================
cmd_setup() {
    validate_config
    detect_platform
    if [ -f "$BLOG_DIR/config.toml" ]; then
        run_update_mode
    else
        run_create_mode
    fi
}

# First run: everything, in an order that settles every remote input before a
# single file is created, so a failure leaves nothing behind.
run_create_mode() {
    check_required_tools
    ensure_zola_installed
    resolve_host_config
    resolve_dates
    resolve_workflow_pin
    create_blog_directory
    write_owned_files
    write_content_if_absent
    write_placeholder_assets_if_absent
    check_menu_pages
    warn_placeholders
    clear_blog_cleanup
    say "blog created in $BLOG_DIR"
    say ""
    init_git_repo
    start_preview_server
}

# Re-run: regenerate the script-owned files from the configuration block;
# content/ is left alone.
run_update_mode() {
    say "found an existing blog in $BLOG_DIR"
    say "updating the script-owned files; content/ is left untouched."
    say ""
    IS_UPDATE=1
    # The drift check comes first, before anything is installed or downloaded, so
    # its "nothing has been changed" is true.
    enter_existing_target
    check_owned_file_drift
    check_menu_pages
    check_required_tools
    ensure_zola_installed
    resolve_host_config
    resolve_workflow_pin
    write_owned_files
    warn_placeholders
    update_git_remote
    say ""
    say "updated."
    start_preview_server
}

enter_existing_target() {
    cd "$BLOG_DIR" || die "could not enter blog folder '$BLOG_DIR'"
}

# The script-owned set as "path render_function" pairs: the one list the writer,
# the manifest and the drift check all read. The last three are conditional on
# purpose: a file this run doesn't write (your own favicon, with FAVICON_TEXT="")
# must not be claimed, or the manifest records your bytes and a later run deletes
# them.
owned_files() {
    cat <<'OWNED'
.gitignore render_gitignore
serve render_serve
build render_build
new render_new
attach render_attach
README.md render_readme
config.toml render_site_config
templates/base.html render_base_html
templates/components.html render_components
templates/index.html render_index_html
templates/page.html render_page_html
templates/section.html render_section_html
templates/taxonomy_list.html render_taxonomy_list
templates/taxonomy_single.html render_taxonomy_single
templates/404.html render_404_html
templates/atom.xml render_atom_xml
OWNED
    [ -n "$FAVICON_MARK" ] && printf '%s\n' "static/favicon.svg render_favicon_svg"
    [ "$ENABLE_SEARCH" = true ] && printf '%s\n' "static/search.js render_search_js"
    [ -n "$GIT_REPO_URL" ] && printf '%s\n' ".github/workflows/deploy.yml render_github_workflow"
    return 0
}

# What the last run wrote, one "sha256  path" line per owned file. Tracked, not
# ignored, so it travels with the repo and a checkout restores a consistent pair.
readonly OWNED_MANIFEST=".zola-blog-setup.manifest"

# The digest the manifest records for one path, or nothing.
manifest_sha() {
    awk -v p="$1" '$2 == p { print $1 }' "$OWNED_MANIFEST"
}

# Stop before overwriting a hand edit. Compares each file with the manifest (what
# the last run wrote), not with what this run would write, so a changed setting
# or a newer script never looks like an edit. No manifest: every owned file
# counts as edited, the safe answer for a blog that predates it.
check_owned_file_drift() {
    [ "$REGENERATE_TEMPLATES" = true ] && return 0
    local path fn recorded actual drifted=() no_manifest=""
    if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then
        warn "no sha256sum or shasum: cannot tell a hand edit from an update, so the drift check is skipped."
        return 0
    fi
    [ -f "$OWNED_MANIFEST" ] || no_manifest=1
    while read -r path fn; do
        [ -f "$path" ] || continue
        [ "$path" = README.md ] && continue   # yours once edited: readme_is_yours
        if [ -n "$no_manifest" ]; then drifted+=("$path"); continue; fi
        recorded=$(manifest_sha "$path")
        if [ -z "$recorded" ]; then drifted+=("$path"); continue; fi
        actual=$(sha256_of "$path") || continue
        [ "$actual" = "$recorded" ] || drifted+=("$path")
    done < <(owned_files)
    [ "${#drifted[@]}" -eq 0 ] && return 0
    if [ -n "$no_manifest" ]; then
        warn "there is no $OWNED_MANIFEST in this blog, so a hand edit cannot be told from an"
        warn "ordinary update. either it was written by a version of this script that kept no"
        warn "record, or the last run could not write one. every script-owned file is listed:"
    else
        warn "these files have been changed since this script last wrote them, and a re-run"
        warn "would overwrite them:"
    fi
    local f
    for f in "${drifted[@]}"; do warn "  - $f"; done
    warn ""
    warn "they are script-owned: change the matching render_* function or a configuration"
    warn "variable rather than the file. to overwrite them anyway, run once with"
    warn "    REGENERATE_TEMPLATES=true bash zola-blog-setup.sh"
    warn "nothing has been changed."
    exit 1
}

# Record what this run left on disk, so the next run can tell an edit from an
# update. Written last, after every owned file is in place.
write_owned_manifest() {
    local path fn digest
    : > "$OWNED_MANIFEST"
    while read -r path fn; do
        [ -f "$path" ] || continue
        if [ "$path" = README.md ] && [ -n "$README_KEPT" ]; then continue; fi
        # A file that can't be hashed is left out; dropping the whole manifest would
        # make every file look edited on the next run.
        digest=$(sha256_of "$path") || { warn "could not hash $path; leaving it out of $OWNED_MANIFEST"; continue; }
        printf '%s  %s\n' "$digest" "$path" >> "$OWNED_MANIFEST"
    done < <(owned_files)
    return 0
}

# README.md turns into yours once you edit it: nothing builds from it, so keeping
# an edited one can't break the site or hold back a fix, and it's the page GitHub
# shows for the repository, where people edit it. Yours: there, and not the bytes
# the manifest says this script last wrote, or not in the manifest at all.
# Deleting it, or REGENERATE_TEMPLATES=true, gets the script's back.
readme_is_yours() {
    [ "$REGENERATE_TEMPLATES" != true ] && [ -f README.md ] && [ -f "$OWNED_MANIFEST" ] || return 1
    local actual
    actual=$(sha256_of README.md) || return 1
    [ "$actual" != "$(manifest_sha README.md)" ]
}

write_owned_files() {
    mkdir -p templates static
    local path fn
    while read -r path fn; do
        if [ "$path" = README.md ] && readme_is_yours; then
            README_KEPT=1
            if [ -n "$(manifest_sha README.md)" ]; then
                say "README.md was edited, so it's yours now: runs leave it alone (delete it to get the script's back)"
            fi
            continue
        fi
        # A path with no slash is its own ${path%/*}; only make a directory when
        # the entry actually names one.
        [ "$path" = "${path%/*}" ] || mkdir -p "${path%/*}"
        "$fn" > "$path"
    done < <(owned_files)
    chmod +x serve build new attach
    # Owned in both directions: clearing the variable that produced a file removes
    # the file, but only if the manifest says these are the bytes this script wrote.
    if [ -z "$FAVICON_MARK" ]; then
        local why="FAVICON_TEXT is empty" fix="add static/favicon.svg yourself"
        if [ "$FAVICON_TEXT" = "auto" ]; then
            why="SITE_TITLE has no letter A-Z or digit 0-9 for FAVICON_TEXT=\"auto\" to draw"
            fix="set FAVICON_TEXT to up to 3 characters, or add static/favicon.svg yourself"
        fi
        if [ -f static/favicon.svg ]; then
            if remove_if_ours static/favicon.svg; then
                say "removed static/favicon.svg ($why)"
            fi
        elif [ "$IS_UPDATE" != 1 ]; then
            warn "$why, so no favicon was written: $fix."
        fi
    fi
    if [ "$ENABLE_SEARCH" != true ] && [ -f static/search.js ] && remove_if_ours static/search.js; then
        say "removed static/search.js (ENABLE_SEARCH is false)"
    fi
    # Without its _index.md, Zola publishes nothing in content/attachments/ and
    # every @/attachments/ link stops the build.
    if [ -d content/attachments ] && [ ! -f content/attachments/_index.md ]; then
        warn "content/attachments/ has no _index.md, so Zola won't publish what's in it and every @/attachments/ link stops the build. the guide's \"Pictures, GIFs and video\" gives the file."
    fi
    if [ "$MASTHEAD" = image ] && [ ! -f static/logo.svg ]; then
        warn "MASTHEAD is \"image\" but static/logo.svg is not there; the masthead shows an empty box until you add it. Supply a flat silhouette on a transparent ground — it is painted as a mask, so any background in the file masks the whole box solid."
    fi
    write_deploy_workflow
    write_owned_manifest
    local readme=" README.md,"
    [ -z "$README_KEPT" ] || readme=""
    say "wrote the script-owned files (config.toml, templates/, serve, build, new, attach,${readme} .gitignore)"
}

validate_config() {
    case "$MASTHEAD" in
        none|text|image) ;;
        *) die "MASTHEAD must be none, text or image, got: $MASTHEAD" ;;
    esac

    # BLOG_NAME becomes a directory under PROJECT_DIR, so it is a single
    # segment: no slashes, no leading dash or dot, no '..', no whitespace.
    case "$BLOG_NAME" in
        ""|*/*|*\\*|.*|-*|*..*|*" "*|*$'\t'*|*\"*)
            die "BLOG_NAME must be a plain folder name (no slashes, leading dash or dot, spaces, '..', or double quotes), got: '$BLOG_NAME'" ;;
    esac

    # base_url, the post links and the origin remote all assume the https github.com
    # form, and the deploy is GitHub Pages regardless.
    case "$GIT_REPO_URL" in
        "") ;;
        *$'\n'*|*$'\r'*) die "GIT_REPO_URL must not contain newlines or carriage returns" ;;
        https://github.com/*/*) ;;
        *) die "GIT_REPO_URL must be an https github.com URL (https://github.com/user/repo), got: $GIT_REPO_URL" ;;
    esac
    # The repository's main page and nothing after it: a deeper path (/tree/main,
    # ?tab=...) would name a repository that isn't yours.
    if [ -n "$GIT_REPO_URL" ]; then
        local repo_path="${GIT_REPO_URL#https://github.com/}"
        local not_repo_page="GIT_REPO_URL must be the address of the repository's main page, https://github.com/user/repo, with nothing after the repository's name; got: $GIT_REPO_URL"
        repo_path="${repo_path%/}"
        repo_path="${repo_path%.git}"
        case "$repo_path" in
            */*/*|*[!A-Za-z0-9._/-]*) die "$not_repo_page" ;;
            ?*/?*) ;;
            *) die "$not_repo_page" ;;
        esac
    fi

    case "$CUSTOM_DOMAIN" in
        ""|https://*) ;;
        *) die "CUSTOM_DOMAIN must be an https:// URL (got: $CUSTOM_DOMAIN)" ;;
    esac

    validate_bool GENERATE_FEEDS      "$GENERATE_FEEDS"
    validate_bool SHOW_RSS_LINK       "$SHOW_RSS_LINK"
    validate_bool LIGHT_THEME         "$LIGHT_THEME"
    validate_bool SHOW_TOC            "$SHOW_TOC"
    validate_bool SHOW_BREADCRUMBS    "$SHOW_BREADCRUMBS"
    validate_bool SHOW_READING_TIME   "$SHOW_READING_TIME"
    validate_bool SHOW_HISTORY_LINK   "$SHOW_HISTORY_LINK"
    validate_bool SHOW_SUGGEST_EDIT   "$SHOW_SUGGEST_EDIT"
    validate_bool ENABLE_TAGS         "$ENABLE_TAGS"
    validate_bool ENABLE_SEARCH       "$ENABLE_SEARCH"
    validate_bool START_PREVIEW       "$START_PREVIEW"
    validate_bool REGENERATE_TEMPLATES "$REGENERATE_TEMPLATES"

    # Tera prints a format with no % code as it is, the same text for every date.
    case "$DATE_FORMAT" in
        *%*) ;;
        *) die "DATE_FORMAT needs at least one % code, such as \"%Y-%m-%d\" or \"%-d %B %Y\"; got: '$DATE_FORMAT'" ;;
    esac
    validate_menu_pages
    validate_footer_links
}

# One name per word: content/NAME.md, or a folder's content/NAME/_index.md
# (menu_page_path). base.html looks each one up.
validate_menu_pages() {
    local name names=()
    read -r -a names <<< "$MENU_PAGES"
    [ "${#names[@]}" -gt 0 ] || return 0
    for name in "${names[@]}"; do
        case "$name" in
            /*|*/|.*|*/.*|-*|*..*|*[!A-Za-z0-9._/-]*|*.md|index|*/index|_index|*/_index)
                die "MENU_PAGES takes the names of pages in content/, without .md, separated by spaces, such as \"about now\"; got: '$name'" ;;
        esac
    done
}

# Label=address per word. The address must be absolute: a bare "x.com/me" would
# become a link inside the blog.
validate_footer_links() {
    local item label url links=()
    read -r -a links <<< "$FOOTER_LINKS"
    [ "${#links[@]}" -gt 0 ] || return 0
    for item in "${links[@]}"; do
        case "$item" in
            ?*=?*) ;;
            *) die "FOOTER_LINKS takes Label=address pairs separated by spaces, such as \"X=https://x.com/you\"; got: '$item'" ;;
        esac
        label="${item%%=*}"
        url="${item#*=}"
        case "$label" in
            *[\"\\]*) die "a FOOTER_LINKS label can't contain a quote or a backslash; got: '$label'" ;;
        esac
        case "$url" in
            *[\"\\\<\>]*) die "a FOOTER_LINKS address can't contain a quote, backslash or angle bracket; got: '$url'" ;;
            https://?*|http://?*|mailto:?*) ;;
            *) die "a FOOTER_LINKS address must start with https://, http:// or mailto:; got: '$url' for $label" ;;
        esac
    done
}

# The booleans go into config.toml unquoted; unchecked, SHOW_TOC=yes would pass
# here and fail at the next build.
validate_bool() {
    case "$2" in
        true|false) return 0 ;;
        *) die "$1 must be true or false (unquoted), got: '$2'" ;;
    esac
}

# Where a MENU_PAGES name lives, from content/: NAME.md, a page that hidden = true
# keeps out of every list, or NAME/_index.md, a folder: a menu page with pages
# under it. A new blog has neither yet: the page, which write_content_if_absent
# then writes.
menu_page_path() {
    if [ -f "content/$1.md" ]; then
        printf '%s' "$1.md"
    elif [ -f "content/$1/_index.md" ]; then
        printf '%s' "$1/_index.md"
    else
        printf '%s' "$1.md"
    fi
}

# Every MENU_PAGES name needs a page to link to, or the build stops at base.html's
# get_page. Runs before anything is written on a re-run.
check_menu_pages() {
    local name file names=()
    read -r -a names <<< "$MENU_PAGES"
    [ "${#names[@]}" -gt 0 ] || return 0
    for name in "${names[@]}"; do
        if [ -f "content/$name.md" ] && [ -f "content/$name/_index.md" ]; then
            die "MENU_PAGES names $name, but there's both content/$name.md and content/$name/_index.md, and Zola won't build two pages at one address. keep one. nothing has been changed."
        fi
        file="content/$(menu_page_path "$name")"
        if [ ! -f "$file" ]; then
            die "MENU_PAGES names $name, but there's no content/$name.md. make that page first (guide: \"Adding a page to the menu\"), or take $name out of MENU_PAGES. nothing has been changed."
        fi
        if grep -q '^render *[=:] *false' "$file"; then
            die "MENU_PAGES names $name, but $file says render = false, so there is no page to link to. nothing has been changed."
        fi
        # Without hidden, a page is a post: listed if it has a weight, left out if not.
        if [ "${file%/_index.md}" = "$file" ] && ! grep -q '^hidden *[=:] *true' "$file"; then
            die "MENU_PAGES names $name, but $file doesn't say hidden = true, so it counts as a post: listed on the home page if it has a weight, left out of the site if not. add hidden = true under its title. nothing has been changed."
        fi
    done
}

# Example values the script came with, while GIT_REPO_URL publishes the blog.
warn_placeholders() {
    [ -n "$GIT_REPO_URL" ] || return 0
    local left=()
    [ "$SITE_TITLE" != "Myblog" ] || left+=("SITE_TITLE")
    [ "$SITE_DESCRIPTION" != "one-line description" ] || left+=("SITE_DESCRIPTION")
    [ "$SITE_AUTHOR" != "your name" ] || left+=("SITE_AUTHOR")
    case "$FOOTER_LINKS" in *yourhandle*|*npub1yourkeyhere*|*yourrepo*) left+=("FOOTER_LINKS") ;; esac
    if [ -f content/about.md ] \
       && grep -q -e 'you@yourdomain.com' -e 'Your name. One or two sentences' content/about.md; then
        left+=("content/about.md")
    fi
    [ "${#left[@]}" -gt 0 ] || return 0
    local list
    list=$(printf '%s, ' "${left[@]}")
    warn "still the examples the script came with, and GIT_REPO_URL will publish them: ${list%, }. the guide's \"Before you publish\" says what to change."
}

update_git_remote() {
    [ -n "$REMOTE_URL" ] || return 0
    if ! command -v git >/dev/null 2>&1; then
        say "git not installed; remote not updated"
        return
    fi
    # The blog folder's own repository: rev-parse also finds a repository the folder
    # sits inside, and would repoint that one's origin.
    if [ ! -e .git ]; then
        say "not a git repo; remote not updated"
        return
    fi
    if git remote get-url origin >/dev/null 2>&1; then
        # The configured URL: get-url applies url.*.insteadOf and would never match.
        local existing
        existing=$(git config --get remote.origin.url || true)
        if [ "$existing" != "$REMOTE_URL" ]; then
            git remote set-url origin "$REMOTE_URL"
            say "updated remote 'origin' to $REMOTE_URL"
        fi
    else
        git remote add origin "$REMOTE_URL"
        say "added remote 'origin': $REMOTE_URL"
    fi
}

# =============================================================================
# HTTP download and Zola install
# =============================================================================
http_download() {
    local url="$1" dest="$2"
    if command -v curl >/dev/null 2>&1; then
        curl -fsSL --proto '=https' --tlsv1.2 \
            --connect-timeout 30 --max-time 300 "$url" -o "$dest"
    elif command -v wget >/dev/null 2>&1; then
        wget -nv --https-only --timeout=30 --tries=2 "$url" -O "$dest"
    else
        warn "need curl or wget"
        return 1
    fi
}

# sha256 of a file as bare hex. Non-zero (and silent) if neither tool exists.
sha256_of() {
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$1" | awk '{print $1}'
    elif command -v shasum >/dev/null 2>&1; then
        shasum -a 256 "$1" | awk '{print $1}'
    else
        return 1
    fi
}

# Hard-required: one of curl/wget, plus tar on Linux/macOS and unzip on Windows.
check_required_tools() {
    local missing=()

    if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
        missing+=("curl or wget (one is required)")
    fi
    case "$OS" in
        linux|macos) command -v tar   >/dev/null 2>&1 || missing+=("tar") ;;
        windows)     command -v unzip >/dev/null 2>&1 || missing+=("unzip") ;;
    esac

    if [ "${#missing[@]}" -gt 0 ]; then
        warn "missing required tools:"
        local tool
        for tool in "${missing[@]}"; do
            warn "  - $tool"
        done
        exit 1
    fi

    command -v git >/dev/null 2>&1 \
        || warn "warning: git not found - repo init will be skipped."
    if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then
        warn "warning: no sha256sum or shasum - the run will stop rather than install a"
        warn "         binary whose digest it cannot record. install coreutils and re-run."
    fi
}

# The release to install and pin, into ZOLA_VERSION: ZOLA_VERSION_OVERRIDE if
# set, else the latest tag via the releases/latest redirect (no API token, no
# rate limit), refused if it's outside the band.
resolve_zola_release() {
    [ -n "$ZOLA_VERSION" ] && return 0
    if [ -n "${ZOLA_VERSION_OVERRIDE:-}" ]; then
        ZOLA_VERSION="${ZOLA_VERSION_OVERRIDE#v}"; ZOLA_VERSION="v${ZOLA_VERSION}"
        version_in_band "${ZOLA_VERSION#v}" || warn "warning: ZOLA_VERSION_OVERRIDE names ${ZOLA_VERSION}, outside this script's supported band (>= ${ZOLA_MIN_VERSION}, < ${ZOLA_MAX_VERSION}). the templates are written for Tera v2 and may not build."
        return 0
    fi
    local tag=""
    # curl only; a wget-only machine falls through to the API path below.
    if command -v curl >/dev/null 2>&1; then
        tag=$(curl -fsSLI -o /dev/null -w '%{url_effective}\n' \
            https://github.com/getzola/zola/releases/latest 2>/dev/null \
            | sed -n 's#.*/tag/##p' || true)
    fi
    tag="${tag%%[[:space:]]*}"
    if [ -z "$tag" ]; then
        tag=$(http_download https://api.github.com/repos/getzola/zola/releases/latest /dev/stdout 2>/dev/null \
            | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)
        tag="${tag%%[[:space:]]*}"
    fi
    [ -n "$tag" ] || die "could not resolve the latest zola release from GitHub (network?). run this again, or install a ${ZOLA_MIN_VERSION}.x release yourself from https://github.com/getzola/zola/releases and run this again."
    if ! version_in_band "${tag#v}"; then
        die "zola ${tag#v} is the latest release, but this script supports >= ${ZOLA_MIN_VERSION} and < ${ZOLA_MAX_VERSION}.
the templates it writes are written for one template-language generation, and a newer
zola may not run them. nothing has been changed. either update this script, or pin a
tested release for this run, with the newest v${ZOLA_MIN_VERSION} release's last number in place of x:
    ZOLA_VERSION_OVERRIDE=v${ZOLA_MIN_VERSION}.x bash zola-blog-setup.sh
releases: https://github.com/getzola/zola/releases"
    fi
    ZOLA_VERSION="$tag"
}

# Return 0 if dotted-numeric version $1 >= $2.
version_ge() {
    [ "$1" = "$2" ] && return 0
    local lower
    lower=$(printf '%s\n%s\n' "$1" "$2" | sort -V | sed -n '1p')
    [ "$lower" = "$2" ]
}

# Return 0 if $1 is inside [ZOLA_MIN_VERSION, ZOLA_MAX_VERSION). One predicate,
# used by the local-binary check and by the workflow pin, so the two cannot
# disagree about what this script supports.
version_in_band() {
    version_ge "$1" "$ZOLA_MIN_VERSION" && ! version_ge "$1" "$ZOLA_MAX_VERSION"
}

# Keep a local Zola inside the band, else install one. Replacing a newer one is
# said out loud: ~/.local/bin/zola is shared with every other site here.
ensure_zola_installed() {
    local current=""
    if command -v zola >/dev/null 2>&1; then
        current=$(zola --version 2>/dev/null | awk '{print $2}')
    fi
    if [ -n "$current" ] && version_in_band "$current"; then
        ZOLA_VERSION="v${current}"
        say "zola $current present (inside the supported band >= $ZOLA_MIN_VERSION, < $ZOLA_MAX_VERSION); using it."
        return
    fi
    resolve_zola_release
    if [ -n "$current" ] && version_ge "$current" "$ZOLA_MAX_VERSION"; then
        # Replaced only if it lives where install_zola puts one; else it stays beside it.
        local where dest="$HOME/.local/bin/zola"
        [ "$OS" = windows ] && dest="$HOME/bin/zola.exe"
        where=$(command -v zola)
        warn "zola $current, at $where, is newer than this script supports (< $ZOLA_MAX_VERSION)."
        if [ "${where%/*}" = "${dest%/*}" ]; then
            warn "replacing it with ${ZOLA_VERSION#v}: any other zola site on this machine will build"
            warn "with ${ZOLA_VERSION#v} until you reinstall."
        else
            warn "installing ${ZOLA_VERSION#v} as $dest, beside it. a terminal runs whichever comes"
            warn "first in its PATH (guide: \"Two Zolas\")."
        fi
    elif [ -n "$current" ]; then
        say "zola $current is below the $ZOLA_MIN_VERSION floor; installing ${ZOLA_VERSION#v}..."
    else
        say "zola not found; installing ${ZOLA_VERSION#v}..."
    fi
    install_zola "$OS" "$ARCH" "$ZOLA_VERSION"
}

install_zola() {
    local os="$1" arch="$2" version="$3"
    local triple ext=tar.gz

    case "${os}_${arch}" in
        linux_x86_64)    triple="x86_64-unknown-linux-gnu" ;;
        linux_aarch64)   triple="aarch64-unknown-linux-gnu" ;;
        macos_x86_64)    triple="x86_64-apple-darwin" ;;
        macos_aarch64)   triple="aarch64-apple-darwin" ;;
        windows_x86_64)  triple="x86_64-pc-windows-msvc"; ext=zip ;;
        windows_aarch64) die "zola publishes no arm64 Windows build; install zola yourself and re-run" ;;
        *)               die "unsupported platform ${os}/${arch}" ;;
    esac

    local url="https://github.com/getzola/zola/releases/download/${version}/zola-${version}-${triple}.${ext}"

    # No trap here: it would replace cleanup_all and disable the partial-blog
    # cleanup on first runs. cleanup_all already calls cleanup_tmp.
    TMP_ZOLA=$(mktemp -d)
    local archive="$TMP_ZOLA/zola.$ext"

    say "downloading zola $version..."
    http_download "$url" "$archive" \
        || die "could not download zola $version from $url. check the network, and that $version is a release listed at https://github.com/getzola/zola/releases."

    [ -s "$archive" ] || die "download produced an empty file"

    # Zola publishes no checksums, so this digest is printed, not compared; trust
    # rests on https to github.com.
    local digest
    digest=$(sha256_of "$archive") || die "need sha256sum or shasum to record the download's digest"
    say "sha256 of the downloaded archive: $digest"

    if [ "$ext" = "zip" ]; then
        command -v unzip >/dev/null 2>&1 || die "need unzip"
        unzip -q "$archive" -d "$TMP_ZOLA"
    else
        tar xzf "$archive" -C "$TMP_ZOLA"
    fi

    local bin
    bin=$(find "$TMP_ZOLA" -maxdepth 2 \( -name zola -o -name zola.exe \) -print -quit)
    [ -z "$bin" ] && die "zola binary not found"
    chmod +x "$bin"

    # Checked before the export below, or the answer is always yes.
    local on_path=""
    case ":$PATH:" in *":$HOME/.local/bin:"*) on_path=1 ;; esac

    case "$os" in
        linux|macos)
            mkdir -p "$HOME/.local/bin"
            say "installing to $HOME/.local/bin..."
            mv "$bin" "$HOME/.local/bin/zola"
            export PATH="$HOME/.local/bin:$PATH" ;;
        windows)
            mkdir -p "$HOME/bin"
            mv "$bin" "$HOME/bin/zola.exe"
            export PATH="$HOME/bin:$PATH"
            say "installed to $HOME/bin/zola.exe (ensure this is on your PATH)" ;;
    esac

    command -v zola >/dev/null 2>&1 || die "zola not on PATH"
    say "installed: $(zola --version)"

    # New terminals take PATH from the login: on Linux ~/.profile, so the next login;
    # a Mac terminal is a login shell, so a new one is enough.
    if [ -z "$on_path" ]; then
        case "$os" in
            linux)
                say ""
                say "note: $HOME/.local/bin is not on your PATH, so a new terminal will not"
                say "find zola. log out and back in; on Debian, Devuan and Ubuntu that is usually"
                say "all it takes. if a new terminal still cannot find it, add this line to"
                say "$HOME/.profile, then log out and back in again:"
                say "    export PATH=\"\$HOME/.local/bin:\$PATH\""
                ;;
            macos)
                say ""
                say "note: $HOME/.local/bin is not on your PATH, so a new terminal will not"
                say "find zola. add this line to $HOME/.zprofile, then open a new terminal:"
                say "    export PATH=\"\$HOME/.local/bin:\$PATH\""
                ;;
        esac
    fi
}

# =============================================================================
# update-zola — install the latest Zola release (deliberate; verified)
# =============================================================================
cmd_update_zola() {
    detect_platform
    check_required_tools
    local current=""
    if command -v zola >/dev/null 2>&1; then
        current=$(zola --version 2>/dev/null | awk '{print $2}')
    fi
    resolve_zola_release
    if [ -n "$current" ] && [ "v${current}" = "$ZOLA_VERSION" ]; then
        say "zola $current is already the latest (${ZOLA_VERSION#v})."
        return
    fi
    say "updating zola: ${current:-none} -> ${ZOLA_VERSION#v}"
    install_zola "$OS" "$ARCH" "$ZOLA_VERSION"
    say ""
    say "zola is now at ${ZOLA_VERSION#v}."
    say "existing blogs keep the version their workflow pins. re-run zola-blog-setup"
    say "against a blog to re-pin it to this version, then commit and push."
}

# =============================================================================
# Host / project config resolution
# =============================================================================
# Everything host-shaped comes from GIT_REPO_URL (validated above; a trailing /
# or .git is tolerated). CUSTOM_DOMAIN replaces base_url and nothing else.
resolve_host_config() {
    if [ -n "$GIT_REPO_URL" ]; then
        local rest="${GIT_REPO_URL#https://github.com/}"
        rest="${rest%/}"
        rest="${rest%.git}"
        local user="${rest%%/*}"
        local repo="${rest##*/}"
        if [ -z "$user" ] || [ -z "$repo" ]; then
            die "GIT_REPO_URL does not name a user and a repo: $GIT_REPO_URL"
        fi
        GITHUB_REPO="${user}/${repo}"
        # GitHub wants a user site's repository name in lower case, so compare that way:
        # John's john.github.io is a user site, not a project at /john.github.io/.
        local user_lc repo_lc
        user_lc=$(printf '%s' "$user" | tr '[:upper:]' '[:lower:]')
        repo_lc=$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')
        if [ "$repo_lc" = "${user_lc}.github.io" ]; then
            BASE_URL="https://${user}.github.io"
        else
            BASE_URL="https://${user}.github.io/${repo}"
        fi
        [ -n "$CUSTOM_DOMAIN" ] && BASE_URL="${CUSTOM_DOMAIN%/}"
        SOURCE_URL="https://github.com/${GITHUB_REPO}/blob/main/content"
        HISTORY_URL="https://github.com/${GITHUB_REPO}/commits/main/content"
        HISTORY_HINT="Click a commit, then 'Display the rich diff' for rendered prose"
        FORGE_URL="https://github.com/${GITHUB_REPO}"
        REMOTE_URL="git@github.com:${GITHUB_REPO}.git"
    else
        # Local-only: a placeholder base_url (Zola requires one; serve overrides it) and
        # empty repo links, so nothing renders dead.
        BASE_URL="${CUSTOM_DOMAIN:-https://yourusername.github.io}"
        BASE_URL="${BASE_URL%/}"
        SOURCE_URL=""
        HISTORY_URL=""
        HISTORY_HINT=""
        FORGE_URL=""
        REMOTE_URL=""
    fi
}

resolve_dates() {
    TODAY=$(date +%Y-%m-%d)
    YESTERDAY=$(date -d "1 day ago" +%Y-%m-%d 2>/dev/null \
        || date -v-1d +%Y-%m-%d 2>/dev/null || echo "$TODAY")
    TWO_DAYS_AGO=$(date -d "2 days ago" +%Y-%m-%d 2>/dev/null \
        || date -v-2d +%Y-%m-%d 2>/dev/null || echo "$TODAY")
}

# =============================================================================
# Filesystem and git
# =============================================================================
create_blog_directory() {
    [ -e "$BLOG_DIR" ] && die "'$BLOG_DIR' exists but holds no config.toml; move it aside or point BLOG_NAME somewhere else."
    say ""
    say "creating $BLOG_DIR ..."
    # Just the folder; each writer makes the subfolders it fills.
    mkdir -p "$BLOG_DIR" \
        || die "could not create $BLOG_DIR (is PROJECT_DIR writable?)"
    cd "$BLOG_DIR"
    BLOG_CREATED="$BLOG_DIR"   # armed for cleanup_partial_blog until the blog is complete
}

init_git_repo() {
    if ! command -v git >/dev/null 2>&1; then
        say "git not installed - skipping repo init (blog still works)"
        return
    fi
    if git init -b main >/dev/null 2>&1; then
        say "initialised git repo on branch: main"
    else
        # Stop: add and commit would reach a repository the blog folder sits inside.
        say "git init failed - run manually later"
        return
    fi
    local name email
    name=$(git config --get user.name  2>/dev/null || true)
    email=$(git config --get user.email 2>/dev/null || true)
    if [ -n "$name" ] && [ -n "$email" ]; then
        if git add -A >/dev/null 2>&1 && \
           git commit -q -m "initial commit: zola blog scaffolding" >/dev/null 2>&1; then
            say "made initial commit"
        else
            say "(initial commit skipped)"
        fi
    else
        say "(git user.name/user.email not configured - initial commit skipped)"
        say "(set them later with:"
        say "     git config --global user.name \"Your Name\""
        say "     git config --global user.email \"Your Email\" )"
    fi
    update_git_remote
}

# exec replaces this process, so the EXIT trap never fires: clean up first. It runs
# the blog's own serve, so this preview and ./serve are one command (drafts shown);
# through bash, so a folder that can't run programs still gets it.
# Skipped with START_PREVIEW=false or no terminal, so a script can drive this one.
start_preview_server() {
    if [ "$START_PREVIEW" != true ] || [ ! -t 1 ]; then
        cleanup_tmp
        say ""
        say "skipping the live preview. start it any time, from anywhere:"
        say "    $BLOG_DIR/serve"
        return 0
    fi
    say ""
    say "starting the live preview (your browser will open). Ctrl+C to stop."
    say "to start it again later, from anywhere: $BLOG_DIR/serve"
    say ""
    cleanup_tmp
    exec bash "$BLOG_DIR/serve"
}

# =============================================================================
# Writers (thin orchestrators dispatching to the render_* functions below)
# =============================================================================
# content/ and the placeholder assets: written once, never rewritten.
write_content_if_absent() {
    mkdir -p content/attachments
    write_absent content/_index.md                render_home_index
    write_absent content/attachments/_index.md    render_attachments_index
    write_absent content/about.md                 render_about
    write_absent content/hello-world.md           render_hello_world
    write_absent content/attachments/demo.svg     render_demo_svg
    write_absent content/second-post.md           render_second_post
    # A starter page for each other MENU_PAGES name, so a new blog builds.
    local name names=()
    read -r -a names <<< "$MENU_PAGES"
    if [ "${#names[@]}" -gt 0 ]; then
        for name in "${names[@]}"; do
            if [ -e "content/$name.md" ] || [ -e "content/$name/_index.md" ]; then continue; fi
            [ "$name" = "${name%/*}" ] || mkdir -p "content/${name%/*}"
            render_menu_page "$name" > "content/$name.md"
        done
    fi
    say "wrote content/ (yours from here; a re-run will not touch it)"
}

write_placeholder_assets_if_absent() {
    mkdir -p static/fonts
    write_absent static/avatar.svg        render_avatar_svg
    write_absent static/fonts/README.txt  render_fonts_readme
}

write_absent() {
    local path="$1" fn="$2"
    [ -e "$path" ] && return 0
    "$fn" > "$path"
}

# Does the tag name a downloadable linux asset? One HEAD before a 15 MB download.
release_asset_exists() {
    local tag="$1"
    local url="https://github.com/getzola/zola/releases/download/${tag}/zola-${tag}-x86_64-unknown-linux-gnu.tar.gz"
    if command -v curl >/dev/null 2>&1; then
        curl -fsSLI -o /dev/null --connect-timeout 30 --max-time 60 "$url" >/dev/null 2>&1
    elif command -v wget >/dev/null 2>&1; then
        wget -q --spider --timeout=30 --tries=1 "$url" >/dev/null 2>&1
    else
        return 1
    fi
}

# The version the workflow pins: the local one only if it's inside the band and a
# real release asset (a distro build such as 0.22.1+dfsg names no release), else
# the latest release. One exact release, not the newest on every build: Zola is
# pre-1.0 and even patch releases change what a template does (0.23.5 brought back
# the date filter's locale), so a pinned build keeps doing what the preview did,
# and only a fixed file can have its checksum recorded.
resolve_zola_version_for_workflow() {
    [ -n "$CI_ZOLA_VERSION" ] && return 0
    local local_ver=""
    if command -v zola >/dev/null 2>&1; then
        local_ver=$(zola --version 2>/dev/null | awk '{print $2}')
    fi
    # A version this blog's workflow already pins was a real release when it was
    # recorded, so keeping it needs no network.
    if [ -n "$local_ver" ] && version_in_band "$local_ver" \
       && { recorded_workflow_sha "v${local_ver}" >/dev/null || release_asset_exists "v${local_ver}"; }; then
        CI_ZOLA_VERSION="v${local_ver}"
        say "pinning zola ${local_ver} into the workflow (same build as this machine)"
        return 0
    fi
    # ensure_zola_installed left the local version in ZOLA_VERSION; cleared, or
    # resolve_zola_release returns it and the version just rejected gets pinned.
    ZOLA_VERSION=""
    resolve_zola_release
    CI_ZOLA_VERSION="$ZOLA_VERSION"
    if [ -n "$local_ver" ]; then
        # ensure_zola_installed has replaced an out-of-band zola, so two causes remain.
        say "local zola ${local_ver} isn't a published release, or GitHub couldn't be reached"
        say "to check; pinning ${CI_ZOLA_VERSION#v} into the workflow instead."
    else
        say "pinning zola ${CI_ZOLA_VERSION#v} into the workflow"
    fi
}

# The digest is recorded once, at setup, and CI checks against it; one fetched
# beside the file at build time would prove nothing. Nothing here can vouch for
# the bytes when first recorded: every source is github over https.
resolve_zola_sha_for_workflow() {
    [ -n "$ZOLA_SHA256" ] && return 0
    if ZOLA_SHA256=$(recorded_workflow_sha "$CI_ZOLA_VERSION"); then
        say "reusing the digest already pinned in .github/workflows/deploy.yml for ${CI_ZOLA_VERSION#v}"
    else
        local url tmpdir
        # Always the Linux x86_64 build the runner uses; downloaded only to be hashed.
        url="https://github.com/getzola/zola/releases/download/${CI_ZOLA_VERSION}/zola-${CI_ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
        tmpdir=$(mktemp -d)
        say "hashing the linux zola ${CI_ZOLA_VERSION#v} build to pin it into the workflow..."
        if http_download "$url" "$tmpdir/zola.tar.gz" && [ -s "$tmpdir/zola.tar.gz" ]; then
            ZOLA_SHA256=$(sha256_of "$tmpdir/zola.tar.gz") || ZOLA_SHA256=""
        fi
        rm -rf "$tmpdir"
    fi
    [ -n "$ZOLA_SHA256" ] || die "could not hash the linux zola ${CI_ZOLA_VERSION} build, which the workflow pins; nothing was created. check the network and run this again."
    say "pinned the zola builder digest into the workflow"
}

# Reuse the digest deploy.yml already pins for this version: no download, works
# offline, proves the same. A version change forces a fresh hash.
recorded_workflow_sha() {
    # Absolute: resolve_workflow_pin runs before the cd into the blog folder.
    local want="$1" wf="$BLOG_DIR/.github/workflows/deploy.yml" pinned_ver pinned_sha
    [ -f "$wf" ] || return 1
    pinned_ver=$(sed -n 's|.*releases/download/\(v[0-9][0-9.]*\)/zola-.*|\1|p' "$wf" | head -n1)
    [ -n "$pinned_ver" ] && [ "$pinned_ver" = "$want" ] || return 1
    pinned_sha=$(sed -n 's|^[[:space:]]*echo "\([0-9a-f]\{64\}\)[[:space:]].*|\1|p' "$wf" | head -n1)
    [ -n "$pinned_sha" ] || return 1
    printf '%s' "$pinned_sha"
}

# Settle everything the workflow pins before a single file is created, so a
# failure here leaves no half-written blog behind.
resolve_workflow_pin() {
    [ -n "$GIT_REPO_URL" ] || return 0   # no workflow, nothing to pin
    resolve_zola_version_for_workflow
    resolve_zola_sha_for_workflow
}

# The workflow is written by the owned_files loop. This is the removal side.
write_deploy_workflow() {
    if [ -n "$GIT_REPO_URL" ]; then
        :
    elif [ -f .github/workflows/deploy.yml ]; then
        # GIT_REPO_URL was cleared: a leftover workflow would keep deploying against the
        # placeholder base_url. Removed only if unedited; kept, with a warning, if not.
        local rc=0
        remove_if_ours .github/workflows/deploy.yml || rc=$?
        if [ "$rc" -eq 0 ]; then
            rmdir .github/workflows .github 2>/dev/null || true
            say "removed .github/workflows/deploy.yml (GIT_REPO_URL is empty); commit the deletion to stop the deploy"
        else
            if [ "$rc" -eq 1 ]; then
                warn "GIT_REPO_URL is empty but .github/workflows/deploy.yml has been edited since this script wrote it, so it was left in place."
            else
                warn "GIT_REPO_URL is empty, but there is no record of this script having written .github/workflows/deploy.yml, so it was left in place."
            fi
            warn "it will keep deploying on every push, and base_url has reverted to a placeholder. delete it yourself when you have saved what you changed."
        fi
    fi
}

# Remove an owned file the configuration no longer calls for, only if its bytes
# are the ones this script last wrote (the manifest says which).
#   0  removed
#   1  edited since this script wrote it: kept
#   2  no record either way: kept, and not called edited
remove_if_ours() {
    local path="$1" recorded actual
    [ -f "$OWNED_MANIFEST" ] || return 2
    recorded=$(manifest_sha "$path")
    [ -n "$recorded" ] || return 2
    actual=$(sha256_of "$path") || return 2
    [ "$recorded" = "$actual" ] || return 1
    rm -f "$path"
}

# =============================================================================
# Templates — one render_* function per file written
# =============================================================================
# Quoted 'EOF' = literal; unquoted EOF expands ${VAR} (those carry a "Vars:" note).

render_gitignore() {
    cat << 'EOF'
# Zola build artifacts
/public
# resize_image output: regenerated from the source images on every build
/static/processed_images

# macOS filesystem metadata
.DS_Store
._*
.AppleDouble/

# Windows filesystem metadata
Thumbs.db
Desktop.ini

# Linux editor backup files
*~

# exiftool's backup of a photo it has cleaned: the untouched original, with the
# location and camera data still in it
*_original
EOF
}

render_serve() {
    cat << 'EOF'
#!/usr/bin/env bash
# preview the blog locally at http://127.0.0.1:1111 with live reload; drafts
# are shown, marked "(draft)", and are never published
cd "$(dirname "$0")"
exec zola serve --open --drafts
EOF
}

# ./new: a post file with its front matter filled in, as a draft at the end of
# the list. Weights are read from the top of content/ only: posts and folders,
# which share one numbering, not children.
# The title is made from the name, so it needs no escaping; the author edits it.
render_new() {
    cat << 'EOF'
#!/usr/bin/env bash
# make a new post, as a draft at the end of the list: ./new tea-in-the-hills
set -euo pipefail
cd "$(dirname "$0")"
name="${1:-}"
case "$name" in
    ""|-*|*[!a-z0-9-]*) name="" ;;
esac
if [ -z "$name" ] || [ "$#" -ne 1 ]; then
    echo "usage: ./new name-of-the-post   (lowercase letters, digits and dashes: it becomes the address, and the title until you change it)" >&2
    exit 2
fi
for taken in "content/$name.md" "content/$name"; do
    if [ -e "$taken" ]; then
        echo "$taken already exists; choose another name" >&2
        exit 1
    fi
done
# The next free weight: 10 past the heaviest post or folder at the top of content/.
max=$(cat content/*.md content/*/index.md content/*/_index.md 2>/dev/null \
    | sed -n 's/^weight *[=:] *\([0-9][0-9]*\).*/\1/p' | sort -n | tail -n 1 || true)
weight=$(( ${max:-0} + 10 ))
# The title: the name with spaces for dashes and a capital first letter.
title="${name//-/ }"
title="$(printf '%s' "${title:0:1}" | tr '[:lower:]' '[:upper:]')${title:1}"
cat > "content/$name.md" << POST
+++
title = "$title"
weight = $weight
date = $(date +%Y-%m-%d)
draft = true
+++

POST
echo "made content/$name.md, a draft at weight $weight: ./serve shows it, and deleting its draft line publishes it."
EOF
}

# ./attach: copies a picture or video into content/attachments/ with its hidden
# data stripped. exiftool -o writes a new file and leaves the original alone;
# -tagsfromfile @ -orientation keeps the one tag a photo needs to stand upright.
render_attach() {
    cat << 'EOF'
#!/usr/bin/env bash
# copy a picture, GIF or video into content/attachments/ without its location
# and camera data: ./attach ~/Pictures/IMG_0001.jpg tea-hills
set -euo pipefail
src="${1:-}" name="${2:-}"
if [ ! -f "$src" ] || [ -z "$name" ]; then
    echo "usage: ./attach path/to/file new-name   (the file keeps its own extension)" >&2
    exit 2
fi
case "$(basename "$src")" in
    ?*.?*) ;;
    *) echo "$src has no extension, such as .jpg or .mp4, to tell what kind of file it is" >&2; exit 2 ;;
esac
ext="$(printf '%s' "${src##*.}" | tr '[:upper:]' '[:lower:]')"
case "$ext" in
    heic|heif) echo "most browsers can't show HEIC pictures; export it as a JPEG first" >&2; exit 2 ;;
    svg) echo "exiftool can't clean SVG drawings: save it as a plain SVG (in Inkscape, File, Save As, Plain SVG), then copy it into content/attachments/ yourself" >&2; exit 2 ;;
esac
name="${name%."$ext"}"
case "$name" in
    ""|-*|*[!a-z0-9-]*)
        echo "the new name takes lowercase letters, digits and dashes, such as tea-hills" >&2
        exit 2 ;;
esac
if ! command -v exiftool >/dev/null 2>&1; then
    echo "needs exiftool: on Debian, Devuan or Ubuntu, sudo apt install libimage-exiftool-perl; on a Mac, brew install exiftool; otherwise see exiftool.org" >&2
    exit 1
fi
src="$(cd "$(dirname "$src")" && pwd)/$(basename "$src")"
cd "$(dirname "$0")"
dest="content/attachments/$name.$ext"
if [ -e "$dest" ]; then
    echo "$dest already exists; choose another name" >&2
    exit 1
fi
if ! exiftool -q -q -all= -tagsfromfile @ -orientation -o "$dest" "$src"; then
    echo "exiftool couldn't clean this file, so nothing was copied. it can't write every kind of file: WebM video is one it can't." >&2
    exit 1
fi
echo "copied to $dest, without its location and camera data. in a post, write:"
case "$ext" in
    mp4|m4v|mov|webm) echo "{{<video src=\"attachments/$name.$ext\" />}}" ;;
    *) echo "![what it shows](@/attachments/$name.$ext)" ;;
esac
EOF
}

render_build() {
    cat << 'EOF'
#!/usr/bin/env bash
# build the static site into ./public for deployment
set -euo pipefail
cd "$(dirname "$0")"

# Zola drops a page missing its section's sort field, warns, exits 0, and still
# lists its URL in sitemap.xml: turn that warning into a failure.
out=$(zola build 2>&1) || { printf '%s\n' "$out" >&2; exit 1; }
printf '%s\n' "$out"
if printf '%s\n' "$out" | grep -q 'page(s) ignored'; then
    {
        printf '\n%s\n' "the page(s) named above produced no HTML, but sitemap.xml still lists"
        printf '%s\n'    "their URLs, so the published site would point crawlers at a 404."
        printf '%s\n'    "give the page a weight, or add hidden = true to its front matter if it"
        printf '%s\n'    "should exist without being listed. nothing was published."
    } >&2
    exit 1
fi
EOF
}

# Vars: BLOG_NAME, BASE_URL, FORGE_URL, GIT_REPO_URL.
render_readme() {
    if [ -n "$GIT_REPO_URL" ]; then
        cat << EOF
# ${BLOG_NAME}

Live at <${BASE_URL}>.

Personal blog built with [Zola](https://www.getzola.org/). Pushing to \`main\`
publishes it through GitHub Actions (<${FORGE_URL}>).
EOF
    else
        cat << EOF
# ${BLOG_NAME}

Personal blog built with [Zola](https://www.getzola.org/). Not published yet:
set GIT_REPO_URL in the setup script and run it again.
EOF
    fi
    cat << 'EOF'

## Made with zola-blog-setup

These files are script-owned, rewritten on every run of the setup script:
`config.toml`, `templates/`, `serve`, `build`, `new`, `attach` and
`.gitignore`; also `static/favicon.svg` while FAVICON_TEXT draws it,
`static/search.js` while search is on, and `.github/workflows/deploy.yml` while
GIT_REPO_URL is set.
Change them through the script's settings or its `render_` functions, not here.
`content/` and the rest of `static/` are yours: the script never touches them
after the first run. So is this README, once you edit it.

How to write posts and change the blog: the guide that comes with the setup
script, zola-blog-guide.md.
EOF
}

# Vars: most of the configuration block, FAVICON_MARK, and BASE_URL, SOURCE_URL,
# HISTORY_URL and HISTORY_HINT from resolve_host_config. Post ordering lives in
# content/_index.md (sort_by), not here.
render_site_config() {
    local burl title_e desc auth langv lightt darkt surl hurl hhint datef foot_text masthead_v
    burl=$(toml_escape "$BASE_URL")
    masthead_v=$(toml_escape "$MASTHEAD")
    title_e=$(toml_escape "$SITE_TITLE")
    desc=$(toml_escape "$SITE_DESCRIPTION")
    auth=$(toml_escape "$SITE_AUTHOR")
    langv=$(toml_escape "$SITE_LANGUAGE")
    lightt=$(toml_escape "$LIGHT_CODE_THEME")
    darkt=$(toml_escape "$DARK_CODE_THEME")
    surl=$(toml_escape "$SOURCE_URL")
    hurl=$(toml_escape "$HISTORY_URL")
    hhint=$(toml_escape "$HISTORY_HINT")
    datef=$(toml_escape "$DATE_FORMAT")
    foot_text=$(toml_escape "$FOOTER_TEXT")
    local menu="" footer="" favicon_drawn=false item names=() links=()
    [ -n "$FAVICON_MARK" ] && favicon_drawn=true
    read -r -a names <<< "$MENU_PAGES"
    if [ "${#names[@]}" -gt 0 ]; then
        for item in "${names[@]}"; do menu+="\"$(toml_escape "$(menu_page_path "$item")")\", "; done
        menu="${menu%, }"
    fi
    read -r -a links <<< "$FOOTER_LINKS"
    if [ "${#links[@]}" -gt 0 ]; then
        for item in "${links[@]}"; do
            footer+="    { name = \"$(toml_escape "${item%%=*}")\", url = \"$(toml_escape "${item#*=}")\" },"$'\n'
        done
    fi
    cat << EOF
# Written by zola-blog-setup from its settings: change the script, not this file.
base_url = "${burl}"
title = "${title_e}"
description = "${desc}"
author = "${auth}"
default_language = "${langv}"

generate_feeds = ${GENERATE_FEEDS}
feed_filenames = ["atom.xml"]
minify_html = true
# The search box's index; its format is under [search].
build_search_index = ${ENABLE_SEARCH}

# render = false keeps tags parseable but builds no tag pages or feeds; feed
# follows generate_feeds (they are separate switches in Zola).
taxonomies = [
    { name = "tags", feed = ${GENERATE_FEEDS}, render = ${ENABLE_TAGS} },
]

[search]
# Plain JSON for static/search.js; the default format adds a third-party library.
index_format = "fuse_json"

[markdown]
# Outbound links get target="_blank" rel="nofollow noopener noreferrer".
external_links_target_blank = true
external_links_no_follow = true
external_links_no_referrer = true
# Footnotes collected at the foot of the post with back-references (Zola 0.20+).
bottom_footnotes = true

[markdown.highlighting]
light_theme = "${lightt}"
dark_theme = "${darkt}"

[extra]
# Read by the templates; each value comes from a setting in the setup script.
masthead = "${masthead_v}"

# true while the script draws static/favicon.svg, so its URL can carry a hash;
# false for your own file, which may be missing (a missing file's hash fails).
favicon_drawn = ${favicon_drawn}

light_theme = ${LIGHT_THEME}

show_toc = ${SHOW_TOC}
show_breadcrumbs = ${SHOW_BREADCRUMBS}
show_reading_time = ${SHOW_READING_TIME}
date_format = "${datef}"
enable_tags = ${ENABLE_TAGS}
show_history_link = ${SHOW_HISTORY_LINK}
show_suggest_edit = ${SHOW_SUGGEST_EDIT}

# Links to each post's file and history on GitHub; empty for a local blog.
source_url = "${surl}"
history_url = "${hurl}"
history_hint = "${hhint}"

# The menu: Search, then these pages, by their paths from content/, labelled with
# their titles, then Tags and RSS; Search, Tags and RSS as the settings allow
# (base.html), and Home first when masthead is "none". A path ending _index.md is
# a folder's page.
menu_pages = [${menu}]
show_rss_link = ${SHOW_RSS_LINK}

# The footer: FOOTER_LINKS, then FOOTER_TEXT under them.
footer_links = [
${footer}]
footer_text = "${foot_text}"
EOF
}

# sort_by = "weight": the author sets the order. Not paginated: the list is an
# <ol>, and a second page would restart at 1.
render_home_index() {
    cat << 'EOF'
+++
title = "Home"
sort_by = "weight"
# Anything written below the closing +++ shows on the home page, above the list.
+++
EOF
}

# content/attachments/: one folder for every page's pictures, GIFs and videos.
# Rendered, because Zola publishes a section's files, and resolves @/ links to
# them, only when it renders the section; redirect_to sends /attachments/ home.
render_attachments_index() {
    cat << 'EOF'
+++
title = "Attachments"
# Pictures, GIFs and videos for any page, linked as @/attachments/<name>.
# Guide: "Pictures, GIFs and video". Don't add render = false: Zola would then
# publish nothing here, and every @/attachments/ link would stop the build.
sort_by = "none"
in_search_index = false
redirect_to = "/"
+++
EOF
}

# A new blog's starter page for a MENU_PAGES name other than about. Vars: $1,
# the page's path under content/ without .md; the title is its last part,
# capitalised.
render_menu_page() {
    local title="${1##*/}"
    title="${title//-/ }"
    title="$(printf '%s' "${title:0:1}" | tr '[:lower:]' '[:upper:]')${title:1}"
    cat << EOF
+++
title = "$(toml_escape "$title")"
hidden = true
+++

This page is content/$1.md.
EOF
}

# About is a page kept out of every list by hidden = true; page.html gives a
# page without a date a section's title and the photo.
render_about() {
    cat << 'EOF'
+++
title = "About"
# Reached from the menu, and left out of the post list, the feed, search and
# sitemap.xml. Guide: "Adding a page to the menu".
hidden = true
[extra]
# avatar_photo: a photo in content/attachments/, named from content/
# ("attachments/me.jpg"); strip its hidden data first. avatar: a file in
# static/. Guide: "Your photo".
avatar_photo = ""
avatar = "avatar.svg"
+++

## Who

Your name. One or two sentences about what this site is for.

## What I write about

A sentence or two on the kinds of things you post here.

## Contact

Email: you@yourdomain.com
EOF
}

# Markdown style reference, written as content/hello-world.md; its picture is
# content/attachments/demo.svg. Published two days ago and updated today, to show
# both dates; the update does not move it, because the list follows weight.
# Vars: TWO_DAYS_AGO, TODAY.
render_hello_world() {
    cat << EOF
+++
title = "Hello world"
weight = 10
date = ${TWO_DAYS_AGO}
updated = ${TODAY}
[taxonomies]
tags = ["writing", "meta"]
+++

Lorem ipsum dolor sit amet[^1], consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor.

This blog links to its own pages with Zola's internal-link syntax, here is [the second post](@/second-post.md), and to the wider web with ordinary Markdown links, like [the Zola documentation](https://www.getzola.org). Both kinds are explained in the guide.

The home page lists posts in the order of their \`weight\` — this one is 10, the second post is 20 — and numbers them where they land. Editing a post never moves it. The previous/next links at the foot of a post walk that same order. The dates are still here and still shown: this post carries an \`updated\` field, so its header gives both a publish date and a "last updated" date, and the feed reads both.

## Heading 2

Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat.

### Heading 3

Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue.

#### Heading 4

In condimentum facilisis porta. Sed nec diam eu diam mattis viverra.

##### Heading 5

Quis sollicitudin sapien justo in libero. Vestibulum mollis mauris enim.

###### Heading 6

Donec viverra auctor lobortis. Pellentesque eu est a nulla placerat dignissim.

---

## Lists

Unordered with nesting:

- Lorem ipsum dolor sit amet
- Consectetur adipiscing elit
  - Nested item one
  - Nested item two
    - Deeper nested item
- Sed do eiusmod tempor

Ordered with nesting. Number each level the ordinary way, \`1.\`, \`2.\`, \`3.\`, indented four spaces under the item above, and the stylesheet numbers the nested ones 2.1, 2.2:

1. First item
2. Second item
    1. Nested first
    2. Nested second
3. Third item

---

## Text formatting

**Bold text**, *italic text*, ***bold italic***, ~~strikethrough~~, \`inline code\`, and a [link with a title](https://example.com "link title attribute"). An autolink: <https://example.com>.

---

## Blockquote

> Lorem ipsum dolor sit amet, consectetur adipiscing elit.
>
> Integer posuere erat a ante venenatis dapibus posuere velit aliquet.

---

## Notes and collapsibles

An aside is for the remark that would break the paragraph it belongs to.

<aside>

Blank lines around the content are load-bearing — without them the Markdown in
here comes out as literal *asterisks*. That is a CommonMark rule: a raw HTML
block ends at the first blank line.

</aside>

A collapsible hides a digression until it is wanted.

<details>
<summary>the same blank-line rule applies here</summary>

So \`code\`, [links](@/second-post.md), and lists all work:

- like this one

</details>

---

## Table

| Column A   | Column B | Column C |
|------------|----------|----------|
| Lorem      | Ipsum    | Dolor    |
| Sit        | Amet     | Consect  |
| Adipiscing | Elit     | Sed      |

---

## Code blocks

A fenced block tagged with a language gets syntax highlighting:

\`\`\`python
def lorem_ipsum():
    """Return a sample string."""
    items = ["Dolor", "sit", "amet"]
    return " ".join(items)
\`\`\`

A shell example, same pattern:

\`\`\`bash
echo "shell example"
for f in *.md; do
    echo "found: \$f"
done
\`\`\`

Without a language tag, the block is plain monospace with no colour:

\`\`\`
raw text block
no highlighting
\`\`\`

You can highlight specific lines with \`hl_lines\`:

\`\`\`python,hl_lines=2 4-5
def plain_line():
    return "this line is highlighted"

def another():
    return "so are these two lines"
\`\`\`

---

## Images

Pictures live in one folder, \`content/attachments/\`, and a post names them from the \`content\` folder, with \`@/\` in front. That works at any site address, and the build stops if the name is wrong:

![layered gray mountain silhouettes with a faint sun behind them](@/attachments/demo.svg)

The picture is \`content/attachments/demo.svg\`. Delete it along with this post.

[^1]: This is the footnote definition. Zola adds a back-reference link so readers can return to where they were.
EOF
}

# Vars: YESTERDAY. Companion post; target of the internal link above.
render_second_post() {
    cat << EOF
+++
title = "Second post"
weight = 20
date = ${YESTERDAY}
[taxonomies]
tags = ["writing"]
+++

A short companion post. It exists to give the previous/next navigation at the foot of every post somewhere to point, and to be the target of the internal link in the hello-world post.

Unlike hello-world, this post has no \`updated\` field, so its header shows only a publish date.

Delete it together with hello-world.md. Deleted on its own, it breaks the link in hello-world, and the build stops.
EOF
}

render_demo_svg() {
    cat << 'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 240">
  <circle cx="460" cy="62" r="22" fill="#888" fill-opacity="0.35"/>
  <polygon points="0,160 80,100 180,140 280,80 380,130 480,90 600,120 600,240 0,240"
           fill="#888" fill-opacity="0.3"/>
  <polygon points="0,190 100,130 220,170 340,110 460,160 600,140 600,240 0,240"
           fill="#888" fill-opacity="0.5"/>
  <polygon points="0,220 90,170 200,200 320,160 440,190 560,170 600,180 600,240 0,240"
           fill="#888" fill-opacity="0.8"/>
</svg>
EOF
}

# Vars: FAVICON_MARK. Letters on a transparent ground: dark ink, light under
# prefers-color-scheme: dark (the tab bar follows the OS). Not Charter: an SVG
# loaded as an image can't load a webfont. Size follows the mark's length.
render_favicon_svg() {
    local size baseline
    case "${#FAVICON_MARK}" in
        1) size=78; baseline=76 ;;
        2) size=56; baseline=70 ;;
        *) size=40; baseline=64 ;;
    esac
    cat << EOF
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <style>
    .ink { fill: #111; }
    @media (prefers-color-scheme: dark) { .ink { fill: #e8e6da; } }
  </style>
  <text class="ink" x="50" y="${baseline}" font-family="Palatino, Georgia, serif"
        font-size="${size}" text-anchor="middle" font-weight="bold">$(esc_xml "$FAVICON_MARK")</text>
</svg>
EOF
}


# Vars: LIGHT_THEME. Follows the site's theme, not the OS, because it sits on
# the page; the favicon keeps its media query because it sits in the tab bar.
render_avatar_svg() {
    local style
    if [ "$LIGHT_THEME" = true ]; then
        style='    .bg { fill: #f4f2e8; }
    .fg { fill: #b8b6a8; }
    @media (prefers-color-scheme: dark) { .bg { fill: #1b1b18; } .fg { fill: #45443e; } }'
    else
        style='    .bg { fill: #1b1b18; }
    .fg { fill: #45443e; }'
    fi
    cat << EOF
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="placeholder photo">
  <style>
${style}
  </style>
  <rect class="bg" width="120" height="120"/>
  <circle class="fg" cx="60" cy="46" r="22"/>
  <path class="fg" d="M20 112c0-22 18-34 40-34s40 12 40 34z"/>
</svg>
EOF
}

render_fonts_readme() {
    cat << 'EOF'
Charter, the blog's typeface. Download it from
https://practicaltypography.com/charter.html and put the four .woff2 files in
this folder (static/fonts/), named:

    charter_regular.woff2
    charter_italic.woff2
    charter_bold.woff2
    charter_bold_italic.woff2

Until then, readers see a similar serif from their own computer.
Guide: "Fonts".
EOF
}

render_base_html() {
    cat << 'EOF'
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="{% if config.extra.light_theme %}light dark{% else %}dark{% endif %}">
    {#- The preview (zola serve rewrites base_url to http://; a published one is
        always https, both sources validated) gets the same policy with scripts
        allowed, for serve's inline and file reload scripts, so it blocks what the
        published site blocks. No frame-ancestors: ignored in <meta>, and Pages can't
        send headers. form-action has no default-src fallback, so it's spelled out. -#}
    {%- set preview = config.base_url is starting_with(pat="http://") %}
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src {% if preview %}'self' 'unsafe-inline'{% elif config.build_search_index %}'self'{% else %}'none'{% endif %}; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'none'">
    <meta name="referrer" content="no-referrer">
    {#- A page's own description (its subtitle) is its meta description too. -#}
    {%- set meta_description = config.description %}
    {%- if page is defined and page.description %}{% set meta_description = page.description %}
    {%- elif section is defined and section.description %}{% set meta_description = section.description %}{% endif %}
    {%- if meta_description %}
    <meta name="description" content="{{ meta_description }}">
    {%- endif %}
    <title>{% block title %}{{ config.title }}{% endblock %}</title>
    {#- Link previews: chat apps and social sites read og:, and X also needs
        twitter:card. current_url is missing on the 404 page. -#}
    <meta property="og:site_name" content="{{ config.title }}">
    <meta property="og:title" content="{% if page is defined %}{{ page.title }}{% elif section is defined and section.path != "/" %}{{ section.title }}{% else %}{{ config.title }}{% endif %}">
    {%- if meta_description %}
    <meta property="og:description" content="{{ meta_description }}">
    {%- endif %}
    <meta property="og:type" content="{% if page is defined %}article{% else %}website{% endif %}">
    <meta name="twitter:card" content="summary">
    {%- if current_url is defined %}
    <meta property="og:url" content="{{ current_url }}">
    <link rel="canonical" href="{{ current_url }}">
    {%- endif %}
    {#- cachebust: a changed favicon gets a new URL, so a browser drops the one it
        cached (every preview shares 127.0.0.1:1111). Only for the drawn favicon. -#}
    <link rel="icon" href="{{ get_url(path='favicon.svg', cachebust=config.extra.favicon_drawn) }}" type="image/svg+xml">
    {% if config.generate_feeds %}
    <link rel="alternate" type="application/atom+xml" title="RSS" href="{{ get_url(path='atom.xml') }}">
    {% endif %}
    {%- if config.build_search_index %}
    <script src="{{ get_url(path='search.js', cachebust=true) }}" defer></script>
    {%- endif %}
    {#- Previous and Next (post_nav, components.html), worked out once: the
        <link>s here, for readers and extensions; the bar at the foot of
        page.html and section.html. -#}
    {%- set_global reading %}{% if page is defined %}{{<post_nav node={page} extra={config.extra} />}}{% elif section is defined %}{{<post_nav node={section} extra={config.extra} />}}{% endif %}{% endset %}
    {{- reading | split(pat="<!--bar-->") | first | safe }}
    <style>
    /* ===================================================================
       YOUR DESIGN: the tokens and element styles to change (guide: "Colours,
       sizes and typefaces"). STRUCTURE, below, reads them.
       =================================================================== */

    /* Charter, self-hosted in static/fonts/; get_url keeps it working under /REPO. */
    @font-face{font-family:"Charter";src:url("{{ get_url(path='fonts/charter_regular.woff2') }}") format("woff2");font-weight:400;font-style:normal;font-display:swap}
    @font-face{font-family:"Charter";src:url("{{ get_url(path='fonts/charter_italic.woff2') }}") format("woff2");font-weight:400;font-style:italic;font-display:swap}
    @font-face{font-family:"Charter";src:url("{{ get_url(path='fonts/charter_bold.woff2') }}") format("woff2");font-weight:700;font-style:normal;font-display:swap}
    @font-face{font-family:"Charter";src:url("{{ get_url(path='fonts/charter_bold_italic.woff2') }}") format("woff2");font-weight:700;font-style:italic;font-display:swap}

    /* Dark is the default palette: with light_theme false the light block isn't
       written and color-scheme is plain "dark". */
    :root {
        color-scheme: {% if config.extra.light_theme %}light dark{% else %}dark{% endif %};
        --serif: "Charter","Bitstream Charter",Palatino,"Palatino Linotype","Book Antiqua","Noto Serif","Liberation Serif",Georgia,serif;
        --mono: ui-monospace,"DejaVu Sans Mono","Liberation Mono",Menlo,Consolas,monospace;
        --bg: #111;
        --fg: #e8e6da;
        --muted: #aaa8a0;
        --link: #94b9dc;               /* followed links in your text; lists, menu and footer keep their own */
        --accent: #ffb454;             /* inline code, the draft label, the focus ring */
        --surface: #242420;            /* table headers; code blocks keep their theme's ground */
        --inline-bg: #2b281f;          /* inline code: needs to read against the page and the line */
        --inline-border: #3a382f;
        --border: #2c2c28;             /* hairline: boxes, tables, small separators */
        --border-strong: #42423c;      /* the weight that carries a heading rule */
        --control-border: #6e6c64;     /* the search box: 3:1 against the page */
        --measure: 42rem;              /* line length: about 79 characters of Charter, 66 of code */
        --wide: 62rem;                 /* ceiling for .wide pages and .bleed elements */
        --body-size: 1.2rem;
        --body-leading: 1.55;
        --wordmark-size: 1.5em;        /* masthead name and image-mode logo: 1.8rem, above --h2-size, below --h1-size */
        --h1-size: 2.25rem;
        --h2-size: 1.7rem;
        --h3-size: 1.35rem;            /* a step above the text, so it reads as a heading, not a bold line */
        --hairline: 1px solid var(--border-strong); /* rules under titles and hr; 'none' drops them all */
    }
{%- if config.extra.light_theme %}
    @media (prefers-color-scheme: light) {
        :root {
            --bg: #fffff8;
            --fg: #111;
            --muted: #57564e;
            --link: #295a8e;
            --accent: #9d4909;
            --surface: #f4f2e8;
            --inline-bg: #ece3cd;
            --inline-border: #e0d8c2;
            --border: #e5e3d7;
            --border-strong: #c2bda4;
            --control-border: #8a8676;
        }
    }
{%- else %}
    /* Giallo puts color-scheme: light dark inline on every <pre>, which beats the
       inherited "dark"; only an !important author rule beats an inline one. */
    pre { color-scheme: dark !important; }
{%- endif %}

    /* Phones: a smaller title, still bigger than the name (1.5em, 1.8rem). */
    @media (max-width: 36rem) { :root { --h1-size: 1.9rem; } }

    html { background: var(--bg); }   /* paint on html only — no two-tone seam in dark mode */
    /* Three rows, so the footer sits at the bottom of a short page (dvh: vh counts
       a phone's browser bars). minmax(0, 1fr): a long code line scrolls inside its
       block instead of widening the page. */
    body {
        font-family: var(--serif);
        font-size: var(--body-size);
        line-height: var(--body-leading);
        color: var(--fg);
        max-width: var(--measure);
        margin: 2rem auto;
        padding: 0 1rem;
        display: grid;
        grid-template-columns: minmax(0, 1fr);
        grid-template-rows: auto 1fr auto;
        min-height: calc(100dvh - 4rem);
        overflow-wrap: break-word;     /* a long address or word breaks, not the page */
    }
    /* .wide widens the whole page; .bleed widens one element and keeps everything
       else on the measure. Both in one post: .bleed wins. */
    body:has(.wide), body:has(.bleed) { max-width: var(--wide); }
    body:has(.bleed) > header,
    body:has(.bleed) > footer,
    body:has(.bleed) main > :not(.bleed) {
        box-sizing: border-box; max-width: var(--measure); margin-inline: auto;
    }
    main > .bleed { margin-inline: 0; }   /* UA stylesheets give <figure> 40px side margins */
    /* Grid items with auto margins shrink to their content: the masthead would bunch up. */
    body:has(.bleed) > header, body:has(.bleed) > footer { width: 100%; }

    /* A link takes the colour of the text around it, underlined, and turns blue
       once followed (:visited can change colours only). Footnote numbers are
       blue either way. */
    a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
    a:visited { color: var(--link); }
    /* Lists of links keep the text colour, followed or not: every item in them
       is a link anyway. */
    .post-list li > a, .tag-list a, .search-results a { color: var(--fg); }

    main h1, main h2, main h3, main h4, main h5, main h6 { font-weight: bold; line-height: 1.18; }
    /* Margins in em of each heading's own size, so the rhythm survives any base size. */
    /* No rules under headings, except a --- written straight after one (see hr). */
    main h1 { font-size: var(--h1-size); margin: 0.7em 0 0.4em; }
    main h2 { font-size: var(--h2-size); margin: 1.5em 0 0.4em; }
    main h3 { font-size: var(--h3-size); margin: 1.18em 0 0.29em; }
    /* 4 to 6 at text size, told apart by style, never smaller than the text. */
    main h4 { font-size: 1em; font-style: italic; margin: 1.4em 0 0.3em; }
    main h5 { font-size: 1em; font-style: italic; font-weight: normal; margin: 1.3em 0 0.3em; }
    main h6 { font-size: 1em; font-style: italic; font-weight: normal; color: var(--muted); margin: 1.1em 0 0.3em; }
    /* What follows a heading starts right under it. Otherwise its own top margin
       wins over the heading's smaller bottom one (touching margins keep only the
       larger), and a heading sits as far from its text as from the text above.
       The same goes for a first paragraph inside a quote or note, whose margin
       would reach out through the top of its box. A heading after a heading keeps
       its own space above. */
    main :is(h2, h3, h4, h5, h6) + :not(h2, h3, h4, h5, h6),
    main :is(h2, h3, h4, h5, h6) + :not(h2, h3, h4, h5, h6) > :first-child,
    main :is(h2, h3, h4, h5, h6) + details:not(.toc) { margin-top: 0; }

    :not(pre) > code { font-family: var(--mono); font-size: 0.88em; color: var(--accent); background: var(--inline-bg); border: 1px solid var(--inline-border); padding: 0.05em 0.3em; border-radius: 2px; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
    /* No user-select: all (it blocks selecting part of a block) and no background:
       Giallo paints the ground inline, which beats any selector here. */
    pre { font-family: var(--mono); border: 1px solid var(--border); border-radius: 4px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-size: 0.95rem; line-height: 1.45; }
    pre code { font-family: var(--mono); font-weight: normal; background: none; padding: 0; }
    code { font-weight: inherit; }

    /* Not italic: italic already marks dates, subtitles and labels. */
    blockquote { margin-block: 1.15rem; margin-inline: 0; padding-inline-start: 1rem; border-inline-start: 3px solid var(--border); color: var(--muted); }
    main small { font-size: 0.82em; color: var(--muted); }
    main aside { font-size: 0.82em; color: var(--muted); border-inline-start: 2px solid var(--border); padding-inline-start: 1em; margin-block: 1.15rem; }

    hr { border: none; border-block-start: var(--hairline); margin-block: 2.5rem; }
    /* A --- straight after a heading underlines it, rather than dividing the page. */
    main :is(h2, h3, h4, h5, h6):has(+ hr) { margin-bottom: 0.3em; }
    main :is(h2, h3, h4, h5, h6) + hr { margin-bottom: 1rem; }
    /* =================================================================== END DESIGN
       STRUCTURE — layout and components; reads the tokens above.
       =================================================================== */

    /* No rule under the masthead, as none above the footer: space separates both. */
    .site-head { padding-bottom: 1rem; margin-bottom: 2rem; }
    /* Brand left, nav right; each falls flush left when they wrap. Centred, not on
       a shared baseline, which at different sizes offsets the caps by ~8px. */
    .masthead { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 0.4rem 1.4rem; }
    /* The description: home page only, a row of its own under the name and menu.
       On a phone, where the menu wraps under the name, it stays next to the name. */
    .tagline { flex-basis: 100%; order: 1; color: var(--muted); font-style: italic; font-size: 0.9em; line-height: 1.35; margin: 0; }
    @media (max-width: 36rem) { .masthead:has(.brand) .tagline { order: 0; } }
    /* The wordmark is text: an SVG loaded as an image can't use the webfont. */
    /* Size set here, so the name and the image-mode logo share one value. */
    .brand, .brand:visited { display: inline-flex; align-items: center; gap: 0.45rem; text-decoration: none; color: var(--fg); font-size: var(--wordmark-size); }
    .brand-name { font-weight: bold; font-size: 1em; }
    /* Screen-reader-only; the home page's h1 uses it. */
    .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
{%- if config.extra.masthead == "image" %}
    /* static/logo.svg as an alpha mask in the text colour: one file for both
       themes, and a background in the file masks the whole box solid. */
    .brand .logo { display: inline-block; width: 1em; height: 1em; align-self: center;
        background-color: currentColor;
        -webkit-mask: url("{{ get_url(path='logo.svg') }}") center / contain no-repeat;
                mask: url("{{ get_url(path='logo.svg') }}") center / contain no-repeat; }
{%- endif %}
    /* A flex row with column-gap, so nothing trails the last item. Scoped to the
       masthead so it doesn't reach .post-nav. */
    .masthead nav { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 1em; margin: 0; }
    .masthead nav a, .masthead nav a:visited { color: var(--muted); text-decoration: none; }
    /* Brighter, not bold: bold would widen it and shift the links before it. */
    .masthead nav a.current { color: var(--fg); text-decoration: underline; }
    /* The Search button, dressed as one more link. */
    .search-open { font: inherit; color: var(--muted); background: none; border: 0; padding: 0; cursor: pointer; text-underline-offset: 2px; }
    .masthead nav a:hover, .search-open:hover { text-decoration: underline; }

    /* Breadcrumbs, over the title of a page in a folder: quiet, like the menu. The
       -0.55rem keeps that title level with the title on a page without them. */
    .crumbs ol { display: flex; flex-wrap: wrap; list-style: none; margin: -0.55rem 0 0; padding: 0; color: var(--muted); font-size: 0.9em; }
    .crumbs li + li::before { content: "›"; content: "›" / ""; margin: 0 0.5em; }
    .crumbs a, .crumbs a:visited { color: var(--muted); text-decoration: none; }
    .crumbs a:hover { text-decoration: underline; }
    main .crumbs + h1 { margin-top: 0.2em; }

    /* A deck under the title, not a caption: full body size. */
    .subtitle { color: var(--muted); font-style: italic; line-height: 1.35; margin: 0.2rem 0 2rem 0; }
    h1 + .subtitle { margin-top: 0; }
    .subtitle:has(+ .post-meta) { margin-bottom: 0.6rem; }   /* a post's subtitle, then its date line */

    /* Drafts appear only in the preview. */
    .draft { color: var(--accent); font-style: italic; font-weight: normal; font-size: 0.8em; }

    .avatar { display: block; width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); margin: 0 0 1.5rem; }

    /* One rule, closing the title-and-dates block. */
    main h1.post-title { margin-bottom: 0.25em; }
    .post-meta { display: flex; flex-wrap: wrap; column-gap: 1.6em; align-items: baseline; color: var(--muted); font-style: italic; font-size: 0.9em; padding-bottom: 0.8rem; margin-bottom: 1.5rem; border-bottom: var(--hairline); overflow: clip; overflow-clip-margin: 4px; }
    .post-meta > * { white-space: nowrap; }   /* wraps between items, never inside one */
    /* Each · sits in the gap before its item; one that would start a wrapped line
       lands past the left edge and is clipped. The clip margin keeps focus rings. */
    .post-meta > * + * { margin-inline-start: -1.6em; }
    .post-meta .sep { display: inline-block; width: 1.6em; text-align: center; }
    .post-meta a, .post-meta a:visited, .subtitle a, .subtitle a:visited { color: var(--muted); text-decoration: none; }
    .post-meta a:hover, .subtitle a:hover { text-decoration: underline; }
    .subtitle .sep { margin: 0 0.7em; }

    sup.footnote-reference { font-size: 0.75em; }
    /* Blue before it's followed too: a footnote number has no underline to mark it. */
    sup.footnote-reference a, sup.footnote-reference a:visited { color: var(--link); text-decoration: none; }
    /* Zola's bottom footnotes: <section class="footnotes"><ol>. A selector that
       stops matching fails silently: the build passes either way. */
    main .footnotes { margin-top: 3rem; padding-top: 0.5rem; border-top: 1px solid var(--border); font-size: 0.8em; color: var(--muted); }
    main .footnotes ol { padding-inline-start: 2.5em; margin: 0; }
    main .footnotes p { margin: 0.3rem 0; }
    /* No top margin on the first note: it would stack on the padding. */
    main .footnotes li:first-child > p:first-child { margin-block-start: 0; }
    /* A post ending in --- would put an <hr> just above the notes' own rule. */
    main hr:has(+ .footnotes) { display: none; }
    /* The arrow back to the text stays quiet, followed or not; a link in a note
       is like any other. Zola's back-links go to #fr-<note>-<use>. */
    main .footnotes a[href*="#fr-"] { color: var(--muted); }

    .giallo-l { display: inline-block; min-height: 1lh; width: 100%; }
    .giallo-ln { display: inline-block; user-select: none; margin-right: 0.4em; padding: 0.4em; min-width: 3ch; text-align: right; opacity: 0.8; }

    main table { border-collapse: collapse; margin: 1rem 0; width: 100%; }
    main th, main td { border: 1px solid var(--border); padding: 0.4rem 0.6rem; text-align: left; }
    main th { background: var(--surface); font-weight: bold; }
    /* Narrow screens: a wide table scrolls inside its own box, not the page. */
    @media (max-width: 44rem) { main table { display: block; overflow-x: auto; } }

    /* Pictures and videos never wider than the column, nor taller than the window. */
    main img, main video { max-width: 100%; height: auto; max-height: 85vh; }

    /* The search box: a modal <dialog> (the browser dims the page and keeps focus
       in the box). search.js sets --search-top to just under the nav. */
    .search { box-sizing: border-box; width: min(var(--measure), calc(100% - 2rem)); max-width: none; max-height: none; margin: var(--search-top, 2rem) auto auto; padding: 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--bg); color: var(--fg); }
    .search::backdrop { background: rgba(0, 0, 0, 0.6); }
    /* min-height: 0 lets the results shrink, and so scroll, under a fixed input. */
    .search-box { box-sizing: border-box; display: flex; flex-direction: column; gap: 0.9rem; max-height: calc(100dvh - var(--search-top, 2rem) - 1rem); padding: 1rem; }
    .search input { font: inherit; box-sizing: border-box; width: 100%; padding: 0.35rem 0.5rem; border: 1px solid var(--control-border); border-radius: 3px; background: var(--bg); color: var(--fg); }
    .search-results { list-style: none; margin: 0; padding: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
    .search-results li + li { margin-top: 0.9rem; }
    .search-excerpt { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.85em; line-height: 1.45; }
    /* Matched words: body colour and bold, not the default yellow. */
    .search-excerpt mark { background: none; color: var(--fg); font-weight: bold; }
    .search-note { color: var(--muted); font-style: italic; }
    /* A visible focus ring: the 1px border at rest is nearly invisible. */
    .search input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

    /* A numbered list inside a numbered list counts 1.1, 1.2.1 (Markdown has no
       "1.1"). A counter can't read <ol start>, so a list Markdown restarted keeps
       the browser's own numbers rather than wrong ones. */
    main ol { counter-reset: outline; }
    main ol > li { counter-increment: outline; }
    main ol ol > li { list-style: none; position: relative; }
    main ol ol > li::before { content: counters(outline, "."); position: absolute; inset-inline-end: 100%; padding-inline-end: 0.25em; }
    main ol[start] ol > li, main ol ol[start] > li { list-style: revert; }
    main ol[start] ol > li::before, main ol ol[start] > li::before { content: none; }

    /* The browser numbers the <ol>; the padding holds the markers, 10 to 99 flush. */
    .post-list { padding-inline-start: 1.7em; }
    .post-list li { margin-bottom: 0.5rem; }
    .post-list li::marker { color: var(--muted); }
    /* Tag pages: no numbers, because a tag's list isn't a sequence. */
    .unordered .post-list { list-style: none; padding-inline-start: 0; }
    .post-list .desc { display: block; color: var(--muted); font-style: italic; font-size: 0.9em; line-height: 1.35; }
    .post-list .tags { display: block; color: var(--muted); font-size: 0.8em; font-style: italic; }
    .post-list .tags a, .post-list .tags a:visited { color: var(--muted); text-decoration: none; }
    .post-list .tags a:hover { text-decoration: underline; }

    /* Quiet links, like the menu's and the date line's: underlined on hover. */
    .post-tags { margin-top: 2.75rem; color: var(--muted); font-size: 0.9em; }
    .post-tags a, .post-tags a:visited { color: var(--muted); text-decoration: none; }
    .post-tags a:hover { text-decoration: underline; }

    .tag-list { list-style: none; padding-inline-start: 0; }
    .tag-list li { margin-bottom: 0.4rem; }
    .tag-list .count { color: var(--muted); font-size: 0.85em; }

    .section-title { padding-bottom: 0.9rem; margin-bottom: 1.4rem; border-bottom: var(--hairline); }
    /* With a subtitle, the rule moves under it, closing the title block. */
    .section-title:has(+ .subtitle) { padding-bottom: 0; margin-bottom: 0.25em; border-bottom: none; }
    .section-title + .subtitle { padding-bottom: 0.9rem; margin-bottom: 1.4rem; border-bottom: var(--hairline); }

    /* One disclosure style for the contents box and any <details> in a post. */
    details > summary { cursor: pointer; color: var(--muted); font-style: italic; list-style: none; }
    details > summary::-webkit-details-marker { display: none; }
    details > summary::before { content: "\25B8"; display: inline-block; width: 1em; }
    details[open] > summary::before { content: "\25BE"; }
    main > details:not(.toc) { margin-block: 1.5rem; }
    /* Open, its text hangs under the summary's words, so it ends visibly. */
    main > details:not(.toc) > :not(summary) { margin-inline-start: 1em; }

    /* The date line's rule under the box, folded or open, so its list ends before the text. */
    .toc { margin: 1.5rem 0; padding: 0 0 1.3rem; border-bottom: var(--hairline); font-size: 0.85em; }
    .toc nav { margin-top: 0.6rem; }
    .toc ul { list-style: none; margin: 0; padding: 0; }
    .toc nav > ul > li { margin: 0.25rem 0; }
    .toc ul ul { padding-left: 1.4em; }
    .toc li { line-height: 1.45; }
    .toc a, .toc a:visited { color: var(--muted); text-decoration: none; }
    .toc a:hover { color: var(--fg); text-decoration: underline; }

    :target { scroll-margin-top: 2rem; }
    main h2:target, main h3:target, main h4:target,
    main h5:target, main h6:target { border-inline-start: 3px solid var(--muted); padding-inline-start: 0.6rem; margin-inline-start: calc(-0.6rem - 3px); }

    .post-nav { margin-top: 2.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 0.9rem; align-items: baseline; }
    /* Tighter only after tags, so they group with the text above them. */
    .post-tags + .post-nav { margin-top: 1.5rem; }
    .post-nav a, .post-nav a:visited { color: var(--muted); text-decoration: none; }
    .post-nav a:hover { text-decoration: underline; }
    .post-nav .label { color: var(--muted); font-style: italic; font-size: 0.9em; margin-right: 0.5em; }
    .post-nav-prev { text-align: left; }
    .post-nav-next { text-align: right; }
    /* All posts: in the middle, or on a phone on a row of its own. Empty on the
       first entry, whose Previous is the home page already. */
    .post-nav-home { text-align: center; }
    @media (max-width: 36rem) {
        .post-nav { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
        .post-nav-home { grid-row: 2; grid-column: 1 / -1; }
        .post-nav-home:empty { display: none; }
        .post-nav .label { display: block; margin-right: 0; }
    }

    /* A margin on both sides of each link keeps the row centred. */
    body > footer { margin-top: 4rem; padding-top: 1rem; text-align: center; color: var(--muted); }
    body > footer a, body > footer a:visited { color: var(--muted); margin: 0 0.4em; text-decoration: none; }
    body > footer a:hover { text-decoration: underline; }
    /* The small print: under the links, smaller and fainter. 85% keeps it at
       4.5:1 or better against the page in both themes. */
    body > footer p { margin: 0.5rem 0 0; font-size: 0.8em; opacity: 0.85; }

    /* Smooth scrolling for the contents and footnote links. No back-to-top button:
       it would cover the text on narrow screens, and hiding it needs a script. */
    @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }

    /* Black on white in print: dark-mode text on paper would be pale. */
    @media print {
        :root, :root * { --bg: #fff; --fg: #000; --muted: #333; --link: #000;
            --accent: #000; --surface: #fff; --inline-bg: #fff;
            --inline-border: #ccc; --border: #bbb; --border-strong: #888; }
        html, body { background: #fff; color: #000; }
        a, a:visited { color: #000; }
        pre { white-space: pre-wrap; }
        /* !important: the highlighter's colours are inline on <pre> and every span. */
        pre, pre span { background: #fff !important; color: #000 !important; }
        .masthead nav, .post-nav, .search, body > footer, .toc { display: none; }
        main h2, main h3, main h4, main h5, main h6 { break-after: avoid; }
        details > summary::before { content: none; }
        main > details:not(.toc) > :not(summary) { display: block; }
    }
    </style>
</head>
<body>
    <header class="site-head">
        <div class="masthead">
        {%- if config.extra.masthead != "none" %}
        <a class="brand" href="{{ get_url(path='/', trailing_slash=true) }}">
            {%- if config.extra.masthead == "image" %}<span class="logo" aria-hidden="true"></span>{% endif %}
            <span class="brand-name">{{ config.title }}</span>
        </a>
        {%- endif %}
        {% block masthead_extra %}{% endblock %}
        {#- Each menu_pages entry, labelled with its title: a page (about.md) or a
            folder's page (projects/_index.md). A missing one stops the build; the
            setup script checks for that first. -#}
        <nav aria-label="Site">
            {%- if config.extra.masthead == "none" %}
            <a href="{{ get_url(path='/', trailing_slash=true) }}"{% if current_path == "/" %} class="current" aria-current="page"{% endif %}>Home</a>
            {%- endif %}
            {%- if config.build_search_index %}
            <button type="button" class="search-open" aria-haspopup="dialog" hidden>Search</button>
            {%- endif %}
            {%- for path in config.extra.menu_pages %}
            {%- if path is ending_with(pat="_index.md") %}{% set menu_item = get_section(path=path) %}{% else %}{% set menu_item = get_page(path=path) %}{% endif %}
            <a href="{{ menu_item.permalink }}"{% if current_path == menu_item.path %} class="current" aria-current="page"{% endif %}>{{ menu_item.title }}</a>
            {%- endfor %}
            {%- if config.extra.enable_tags %}
            <a href="{{ get_url(path='tags', trailing_slash=true) }}"{% if current_path == "/tags/" %} class="current" aria-current="page"{% endif %}>Tags</a>
            {%- endif %}
            {%- if config.generate_feeds and config.extra.show_rss_link %}
            <a href="{{ get_url(path='atom.xml') }}">RSS</a>
            {%- endif %}
        </nav>
        </div>
    </header>

    <main>
        {#- Breadcrumbs: Home, then the folders above this page, top one first, so even
            one folder reads as a trail; the title follows, so it isn't repeated. A page
            with no folder above it has none, and so does a page in a transparent
            folder: Zola leaves that folder out of its ancestors. -#}
        {%- set trail = page?.ancestors or section?.ancestors or [] %}
        {%- if config.extra.show_breadcrumbs and trail | length > 1 %}
        <nav class="crumbs" aria-label="Breadcrumb"><ol>
            <li><a href="{{ get_url(path='/', trailing_slash=true) }}">Home</a></li>
            {%- for path in trail[1:] %}{% set crumb = get_section(path=path) %}
            <li><a href="{{ crumb.permalink }}">{{ crumb.title }}</a></li>
            {%- endfor %}
        </ol></nav>
        {%- endif %}
        {% block content %}{% endblock %}
    </main>

    {% if config.extra.footer_text or config.extra.footer_links %}
    <footer>
        {% for link in config.extra.footer_links -%}
        <a href="{{ link.url }}">{{ link.name }}</a>{% if not loop.last %} · {% endif %}
        {%- endfor %}
        {% if config.extra.footer_text %}<p>{{ config.extra.footer_text }}</p>{% endif %}
    </footer>
    {% endif %}
    {%- if config.build_search_index %}
    {#- Hidden until search.js runs: the dialog is closed and the button hidden.
        data-home is the home section's permalink, the exact string the index holds;
        get_url(path='/') drops the trailing slash. Tera v2 can't read a field off a
        function call, hence the set. -#}
    {%- set search_home = get_section(path='_index.md') %}
    <dialog class="search" aria-label="Search">
        <div class="search-box" data-index="{{ get_url(path='search_index.' ~ lang ~ '.json') }}" data-home="{{ search_home.permalink }}">
            <input type="search" aria-label="Search this site" placeholder="Search" autocomplete="off">
            <p class="visually-hidden" role="status"></p>
            <ul class="search-results" hidden></ul>
        </div>
    </dialog>
    {%- endif %}
</body>
</html>
EOF
}

# The numbered lists (post_list), the contents box, videos, bare addresses as
# links, and Previous and Next along the lists (post_nav, beside). Zola gives
# templates no weights, so front_value reads them from the files.
render_components() {
    cat << 'EOF'
{#- Tera v2 components, not macros (Zola 0.23 removed those), registered
    globally, so nothing imports this file. They can't see config, hence the
    extra argument. -#}

{#- The numbered list on the home page, a folder's page and a tag page.
    folders: the subsections, in weight order; left out are attachments/,
    folders in the menu, and transparent folders, whose pages are listed
    already (Zola drops hidden folders, and drafts outside the preview).
    section: the list's own _index.md. Sorted by weight, a folder goes before
    the first page not lighter than it; otherwise the folders come first. -#}
{% component post_list(pages, extra, folders=[], section="") %}
{%- set skip = ["attachments/_index.md", ...extra.menu_pages] %}
{%- set listed = [f for f in [get_section(path=p) for p in folders] if not f.transparent and f.relative_path not in skip] %}
{%- set sort_by %}{% if section and listed %}{{<front_value path={section} key="sort_by" />}}{% endif %}{% endset %}
{%- if sort_by == "weight" %}
{%- set_global entries = [] %}
{%- set_global taken = 0 %}
{%- for f in listed %}
{%- set w %}{{<front_value path={f.relative_path} key="weight" default="-1" />}}{% endset %}
{%- set n %}{{<lighter pages={pages} weight={w | int} />}}{% endset %}
{%- set n = n | int %}
{%- if n > taken %}{% set_global entries = [...entries, ...pages[taken:n]] %}{% set_global taken = n %}{% endif %}
{%- set_global entries = [...entries, f] %}
{%- endfor %}
{%- set entries = [...entries, ...pages[taken:]] %}
{%- else %}
{%- set entries = [...listed, ...pages] %}
{%- endif %}
<ol class="post-list">
{% for e in entries %}
<li>
    <a href="{{ e.permalink }}">{{ e.title }}</a>{% if e.draft %} <span class="draft">(draft)</span>{% endif %}
    {% if e.description %}<span class="desc">{{ e.description }}</span>{% endif %}
    {% if extra.enable_tags and e.taxonomies?.tags %}<span class="tags">{% for tag in e.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', term=tag) }}">{{ tag }}</a>{% if not loop.last %} · {% endif %}{% endfor %}</span>{% endif %}
</li>
{% endfor %}
</ol>
{% endcomponent post_list %}

{#- The contents box: a post's headings, or those of a page without a date that
    sets toc = true under [extra]. Shows one level of sub-headings. Starts folded,
    so a phone's first screen shows the text. -#}
{% component toc(items) %}
<details class="toc">
<summary>Contents</summary>
<nav aria-label="contents">
<ul>
{% for h in items %}
<li><a href="{{ h.permalink | safe }}">{{ h.title }}</a>
{%- if h.children %}<ul>{% for c in h.children %}<li><a href="{{ c.permalink | safe }}">{{ c.title }}</a></li>{% endfor %}</ul>{%- endif %}
</li>
{% endfor %}
</ul>
</nav>
</details>
{% endcomponent toc %}

{#- A video, by its path from content/ (attachments/<name>). get_url with @/
    stops the build if the file is missing. loop="yes" plays it silently on a
    loop, like a GIF, still with controls: WCAG 2.2.2 wants a way to pause. -#}
{% component video(src, loop="no") %}
{%- set url = get_url(path="@/" ~ src) -%}
{% if loop == "yes" %}<video src="{{ url }}" autoplay loop muted playsinline controls></video>{% else %}<video src="{{ url }}" controls preload="metadata"></video>{% endif %}
{% endcomponent video %}

{#- An address pasted as it is becomes a link, like one written <https://...>:
    Markdown links only the second. The first step matches tags, links and code
    whole, so they pass through untouched, and marks each bare address; the second
    drops the empty marks the tags leave; the third turns the rest into links with
    the attributes Zola gives other outside links. A full stop, comma or bracket
    after an address stays outside it. atom.xml uses it too. -#}
{% component linkify(html) %}{{ html | regex_replace(pattern=`(?s)(<a\\b[^>]*>.*?</a>|<pre\\b.*?</pre>|<code\\b.*?</code>|<script\\b.*?</script>|<style\\b.*?</style>|<[^>]*>)|(https?://(?:[^\\s<>"()&]|&amp;|\\([^\\s<>"()]*\\))*(?:[^\\s<>"().,;:!?'*_~&]|&amp;|\\([^\\s<>"()]*\\)))`, rep=`${1}<!--L-->${2}<!--/L-->`) | replace(from="<!--L--><!--/L-->", to="") | regex_replace(pattern=`<!--L-->(.*?)<!--/L-->`, rep=`<a rel="noopener nofollow noreferrer external" href="$1" target="_blank">$1</a>`) | safe }}{% endcomponent linkify %}

{#- Previous and Next: down the home page's list, and into each folder's own
    list straight after the folder, at any depth, as post_list shows them. The
    home page, a folder in the menu, and a folder inside a transparent one
    (which no list above it shows) each start an order; a page no list shows
    gets no links. Worked out once per page, into <head>: the <link>s, then
    <!--bar-->, then the bar for the foot. -#}
{% component post_nav(node, extra) %}
{%- set skip = ["attachments/_index.md", ...extra.menu_pages] %}
{%- set here = node.relative_path %}
{%- set up = node.ancestors | last %}
{%- set is_folder = here is ending_with(pat="_index.md") %}
{%- set_global start = is_folder and (not up or here in skip) %}
{%- if is_folder and not start %}{% set parent = get_section(path=up) %}{% if parent.transparent %}{% set_global start = not node.transparent %}{% endif %}{% endif %}
{%- set around %}{% if start %}//{% elif up %}{{<beside section={up} item={here} extra={extra} />}}{% endif %}{% endset %}
{%- set placed = around != "" %}
{%- set_global prev = around | split(pat="//") | first %}
{%- set_global next = around | split(pat="//") | last %}
{#- Previous: for the first entry in a list, its folder; when it's a folder,
    that folder's last entry, and on down. -#}
{%- if placed and not start and not prev %}{% set_global prev = up %}
{%- else %}{% for step in range(end=100) %}{% if prev is not ending_with(pat="_index.md") %}{% break %}{% endif %}{% set last %}{{<beside section={prev} item="" extra={extra} />}}{% endset %}{% set last = last | split(pat="//") | first %}{% if not last %}{% break %}{% endif %}{% set_global prev = last %}{% endfor %}{% endif %}
{#- Next: a folder's own first entry; at the end of a list, what follows its
    folder in the list above, and on up to the order's start. -#}
{%- if placed and is_folder %}{% set first %}{{<beside section={here} item="" extra={extra} />}}{% endset %}{% set first = first | split(pat="//") | last %}{% if first %}{% set_global next = first %}{% endif %}{% endif %}
{%- if placed and not start and not next %}
{%- set_global c = up %}
{%- for step in range(end=100) %}
{%- set cs = get_section(path=c) %}
{%- if c == "_index.md" or c in skip or not cs.ancestors %}{% break %}{% endif %}
{%- set g = cs.ancestors | last %}
{%- set gs = get_section(path=g) %}
{%- if gs.transparent %}{% break %}{% endif %}
{%- set after %}{{<beside section={g} item={c} extra={extra} />}}{% endset %}
{%- if not after %}{% break %}{% endif %}
{%- set after = after | split(pat="//") | last %}
{%- if after %}{% set_global next = after %}{% break %}{% endif %}
{%- set_global c = g %}
{%- endfor %}
{%- endif %}
{%- if prev %}{% if prev is ending_with(pat="_index.md") %}{% set_global prev = get_section(path=prev) %}{% else %}{% set_global prev = get_page(path=prev) %}{% endif %}{% endif %}
{%- if next %}{% if next is ending_with(pat="_index.md") %}{% set_global next = get_section(path=next) %}{% else %}{% set_global next = get_page(path=next) %}{% endif %}{% endif %}
{#- A list page that starts an order shows no bar: its list is right there.
    The first entry's Previous is the home page, so All posts would repeat it. -#}
{%- if next %}<link rel="next" href="{{ next.permalink | safe }}">{% endif %}
{%- if prev %}<link rel="prev" href="{{ prev.permalink | safe }}">{% endif %}
<!--bar-->
{%- if placed and not start %}
<nav class="post-nav" aria-label="Previous and next">
<div class="post-nav-prev">{% if prev %}<a href="{{ prev.permalink }}"><span class="label">&larr;&nbsp;Previous</span>{{ prev.title }}</a>{% endif %}</div>
<div class="post-nav-home">{% if prev.relative_path != "_index.md" %}<a href="{{ get_url(path='/', trailing_slash=true) }}">All posts</a>{% endif %}</div>
<div class="post-nav-next">{% if next %}<a href="{{ next.permalink }}"><span class="label">Next</span>{{ next.title }}&nbsp;&rarr;</a>{% endif %}</div>
</nav>
{%- endif %}
{% endcomponent post_nav %}

{#- The entries just before and after item in section's list, the list
    post_list shows, as "before//after" paths ("//" can't occur in a path), one
    of them empty at an end of the list; item "" gives the list's
    "last//first". Nothing at all when the list doesn't show item. -#}
{% component beside(section, item, extra) -%}
{%- set s = get_section(path=section) %}
{%- set skip = ["attachments/_index.md", ...extra.menu_pages] %}
{%- set fs = [f for f in [get_section(path=p) for p in s.subsections] if not f.transparent and f.relative_path not in skip] %}
{%- set ps = s.pages %}
{%- set weighted %}{% if fs and ps %}{{<front_value path={section} key="sort_by" />}}{% endif %}{% endset %}
{%- set weighted = weighted == "weight" %}
{%- set_global f0 = fs | last %}{% set_global f1 = fs | first %}{% set_global p0 = ps | last %}{% set_global p1 = ps | first %}
{%- set_global found = item == "" %}
{%- set_global seen = none %}
{%- if item is ending_with(pat="_index.md") %}
{#- A folder: the folders either side of it, and the pages either side of its weight. -#}
{%- for f in fs %}{% if f.relative_path == item %}{% set_global found = true %}{% set_global f1 = fs | nth(n=loop.index) %}{% break %}{% endif %}{% set_global seen = f %}{% endfor %}
{%- set_global f0 = seen %}
{%- set w %}{% if found and weighted %}{{<front_value path={item} key="weight" default="-1" />}}{% endif %}{% endset %}
{%- set n %}{% if found and weighted %}{{<lighter pages={ps} weight={w | int} />}}{% else %}0{% endif %}{% endset %}
{%- set n = n | int %}
{%- set_global p0 = none %}{% if n > 0 %}{% set_global p0 = ps | nth(n=n - 1) %}{% endif %}
{%- set_global p1 = ps | nth(n=n) %}
{%- elif item %}
{#- A page: its neighbours, from Zola, or found in a list without sort_by; and
    the folders either side of its weight. -#}
{%- set pg = get_page(path=item) %}
{%- set_global p0 = pg.lower %}{% set_global p1 = pg.higher %}
{%- if p0 or p1 %}{% set_global found = true %}
{%- else %}{% for p in ps %}{% if p.relative_path == item %}{% set_global found = true %}{% set_global p0 = seen %}{% set_global p1 = ps | nth(n=loop.index) %}{% break %}{% endif %}{% set_global seen = p %}{% endfor %}{% endif %}
{%- set w %}{% if found and weighted %}{{<front_value path={item} key="weight" default="-1" />}}{% endif %}{% endset %}
{%- set m %}{% if found and weighted %}{{<lighter pages={fs} weight={(w | int) + 1} />}}{% else %}{{ fs | length }}{% endif %}{% endset %}
{%- set m = m | int %}
{%- set_global f0 = none %}{% if m > 0 %}{% set_global f0 = fs | nth(n=m - 1) %}{% endif %}
{%- set_global f1 = fs | nth(n=m) %}
{%- endif %}
{%- if found %}
{#- Before it, the later of the two; after it, the earlier. A folder comes
    first on an equal weight, and in a list not sorted by weight. -#}
{%- if f0 and p0 %}{% set a %}{{<front_value path={f0.relative_path} key="weight" default="-1" />}}{% endset %}{% set b %}{{<front_value path={p0.relative_path} key="weight" default="-1" />}}{% endset %}{% if not weighted or (a | int) <= (b | int) %}{% set_global f0 = none %}{% else %}{% set_global p0 = none %}{% endif %}{% endif %}
{%- if f1 and p1 %}{% set a %}{{<front_value path={f1.relative_path} key="weight" default="-1" />}}{% endset %}{% set b %}{{<front_value path={p1.relative_path} key="weight" default="-1" />}}{% endset %}{% if not weighted or (a | int) <= (b | int) %}{% set_global p1 = none %}{% else %}{% set_global f1 = none %}{% endif %}{% endif %}
{%- set before = f0 or p0 %}{% set after = f1 or p1 %}
{%- if before %}{{ before.relative_path | safe }}{% endif %}//{% if after %}{{ after.relative_path | safe }}{% endif %}
{%- endif %}
{%- endcomponent beside %}

{#- One front matter value of a file in content/, as text, or default: a
    weight or a sort_by, which Zola reads but doesn't give templates. The two
    patterns are Zola's own (front_matter/split.rs), so the front matter ends
    where Zola's does, and a +++ inside a title doesn't end it early. -#}
{% component front_value(path, key, default="") -%}
{%- set raw = load_data(path="@/" ~ path, format="plain") %}
{%- set toml = `^[[:space:]]*\\+\\+\\+[[:space:]]*(\\r?\\n(?s).*?(?-s))\\+\\+\\+[[:space:]]*(?:$|(?:\\r?\\n((?s).*(?-s))$))` %}
{%- set yaml = `^[[:space:]]*---[[:space:]]*(\\r?\\n(?s).*?(?-s))---[[:space:]]*(?:$|(?:\\r?\\n((?s).*(?-s))$))` %}
{%- if raw is matching(pat=toml) %}{% set fm = load_data(literal=raw | regex_replace(pattern=toml, rep="$1"), format="toml") %}
{%- elif raw is matching(pat=yaml) %}{% set fm = load_data(literal=raw | regex_replace(pattern=yaml, rep="$1"), format="yaml") %}
{%- else %}{% set fm = {} %}{% endif %}
{{- fm | get(key=key, default=default) }}
{%- endcomponent front_value %}

{#- How many of these pages or folders, in weight order, are lighter than
    weight: where something of that weight goes among them. Halves the list
    each step, so a list of 2,000 takes 11 reads. -#}
{% component lighter(pages, weight) -%}
{%- set_global lo = 0 %}
{%- set_global hi = pages | length %}
{%- for step in range(end=64) %}
{%- if lo >= hi %}{% break %}{% endif %}
{%- set mid = (lo + hi) // 2 %}
{%- set pm = pages[mid] %}
{%- set wm %}{{<front_value path={pm.relative_path} key="weight" default="-1" />}}{% endset %}
{%- if (wm | int) < weight %}{% set_global lo = mid + 1 %}{% else %}{% set_global hi = mid %}{% endif %}
{%- endfor %}
{{- lo }}
{%- endcomponent lighter %}
EOF
}

# Home: the numbered list, posts and folders in the order their weights give.
# It starts the reading order, so it has a <link rel="next"> and no bar.
render_index_html() {
    cat << 'EOF'
{% extends "base.html" %}

{% block title %}{{ config.title }}{% endblock %}

{#- The description, as a strapline under the name. -#}
{% block masthead_extra %}{% if config.description %}<p class="tagline">{{ config.description }}</p>{% endif %}{% endblock %}

{% block content %}
{#- Hidden, not deleted: the page keeps one h1 for the outline and screen readers. -#}
<h1 class="visually-hidden">{{ config.title }}</h1>

{#- Whatever is written below the front matter of content/_index.md. -#}
{{<linkify html={section.content} />}}

{{<post_list pages={section.pages} extra={config.extra} folders={section.subsections} section={section.relative_path} />}}
{% endblock %}
EOF
}

# A post, or any other page: title, date line, text, tags, then the Previous and
# Next bar, worked out in base.html.
render_page_html() {
    cat << 'EOF'
{% extends "base.html" %}

{% block title %}{{ page.title }} | {{ config.title }}{% endblock %}

{% block content %}
{#- A page without a date, such as About, is titled like a section: the rule
    goes under its title, or under its subtitle. -#}
<h1 class="{% if page.date %}post-title{% else %}section-title{% endif %}">{{ page.title }}{% if page.draft %} <span class="draft">(draft)</span>{% endif %}</h1>
{% if page.description %}<p class="subtitle">{{ page.description }}</p>{% endif %}
{% if page.date %}
{% set pub_date = page.date | date(format="%Y-%m-%d") %}
{% set rev_date = "" %}
{% if page.updated %}{% set rev_date = page.updated | date(format="%Y-%m-%d") %}{% endif %}
{#- One span per item after the date, so the line wraps between items. -#}
<div class="post-meta">
<time datetime="{{ pub_date }}">{{ page.date | date(format=config.extra.date_format) }}</time>
{% if rev_date and rev_date != pub_date %}<span><span class="sep">·</span>Updated <time datetime="{{ rev_date }}">{{ page.updated | date(format=config.extra.date_format) }}</time></span>{% endif %}
{% if config.extra.show_reading_time %}<span><span class="sep">·</span>{{ page.reading_time }} min read</span>{% endif %}
{% if config.extra.show_history_link and config.extra.history_url and page.relative_path %}<span><span class="sep">·</span><a class="history" href="{{ config.extra.history_url }}/{{ page.relative_path }}"{% if config.extra.history_hint %} title="{{ config.extra.history_hint }}"{% endif %}>View history</a></span>{% endif %}
{% if config.extra.show_suggest_edit and config.extra.source_url and page.relative_path %}<span><span class="sep">·</span><a class="source" href="{{ config.extra.source_url }}/{{ page.relative_path }}">Suggest an edit</a></span>{% endif %}
</div>
{% endif %}

{#- The About photo: avatar_photo, shrunk from content/, or avatar, from static/. -#}
{% if page.extra?.avatar_photo %}
{% set av = resize_image(path=page.extra.avatar_photo, width=240, height=240, op="fill") %}
<img class="avatar" src="{{ av.url }}" width="120" height="120" alt="">
{% elif page.extra?.avatar %}<img class="avatar" src="{{ get_url(path=page.extra.avatar) }}" width="120" height="120" alt="">{% endif %}

{#- From two headings, or one with sub-headings: a one-line box is noise. A page
    without a date gets one only when it asks, with toc = true under [extra]. -#}
{% if config.extra.show_toc and page.toc and (page.date or page.extra?.toc) and (page.toc | length > 1 or page.toc[0].children) %}
{{<toc items={page.toc} />}}
{% endif %}

{{<linkify html={page.content} />}}

{% if config.extra.enable_tags and page.taxonomies.tags %}
<div class="post-tags">Tags: {% for tag in page.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', term=tag) }}">{{ tag }}</a>{% if not loop.last %} · {% endif %}{% endfor %}</div>
{% endif %}

{#- The bar: not on a page no list shows, such as About; the masthead already
    leads home. -#}
{{ reading | split(pat="<!--bar-->") | last | safe }}
{% endblock %}
EOF
}

# A section's page: any folder with an _index.md, such as the parent of child
# pages. Its title, subtitle (description), the contents box if it sets
# toc = true under [extra], its text, then its folders and child pages, in the
# same numbered list the home page uses, then the Previous and Next bar.
render_section_html() {
    cat << 'EOF'
{% extends "base.html" %}

{% block title %}{{ section.title }} | {{ config.title }}{% endblock %}

{% block content %}
<h1 class="section-title">{{ section.title }}</h1>
{% if section.description %}<p class="subtitle">{{ section.description }}</p>{% endif %}
{% if config.extra.show_toc and section.extra?.toc and section.toc and (section.toc | length > 1 or section.toc[0].children) %}
{{<toc items={section.toc} />}}
{% endif %}
{% if section.content %}{{<linkify html={section.content} />}}{% endif %}
{% if section.pages or section.subsections %}{{<post_list pages={section.pages} extra={config.extra} folders={section.subsections} section={section.relative_path} />}}{% endif %}
{{ reading | split(pat="<!--bar-->") | last | safe }}
{% endblock %}
EOF
}

# /tags/ — every tag with a count.
render_taxonomy_list() {
    cat << 'EOF'
{% extends "base.html" %}

{% block title %}Tags | {{ config.title }}{% endblock %}

{% block content %}
<h1 class="section-title">Tags</h1>
<ul class="tag-list">
{% for term in terms %}
<li><a href="{{ term.permalink }}">{{ term.name }}</a> <span class="count">{{ term.page_count }}</span></li>
{% endfor %}
</ul>
{% endblock %}
EOF
}

# /tags/<tag>/ — posts carrying one tag.
render_taxonomy_single() {
    cat << 'EOF'
{% extends "base.html" %}

{% block title %}Posts tagged: {{ term.name }} | {{ config.title }}{% endblock %}

{% block content %}
<h1 class="section-title">Posts tagged: {{ term.name }}</h1>
<p class="subtitle">{{ term.page_count }} post{% if term.page_count != 1 %}s{% endif %}<span class="sep">·</span><a href="{{ get_url(path='tags', trailing_slash=true) }}">&larr; All tags</a></p>
<div class="unordered">{{<post_list pages={term.pages} extra={config.extra} />}}</div>
{% endblock %}
EOF
}

# static/search.js, while ENABLE_SEARCH is true, loaded on every page. Fetches
# the index on the first keystroke; word-start matches, titles first. The DOM
# is built with createElement and textContent, never innerHTML.
render_search_js() {
    cat << 'EOF'
// The search box. Written by zola-blog-setup; script-owned.
(function () {
    "use strict";
    var dialog = document.querySelector("dialog.search");
    var button = document.querySelector(".search-open");
    // No <dialog> support, no box: the button stays hidden.
    if (!dialog || !button || typeof dialog.showModal !== "function") return;
    var box = dialog.querySelector(".search-box");
    var input = box.querySelector("input");
    var results = box.querySelector(".search-results");
    // Read out by screen readers, which aren't told the list changed.
    var status = box.querySelector("[role=status]");
    var pages = null;
    var loading = null;
    // A match starts a word: nothing, or a non-letter, non-digit, comes before it.
    var WORD_START = "(^|[^\\p{L}\\p{N}])";
    // The index stores text with HTML entities (&amp;, &lt;); DOMParser decodes
    // them without running or fetching anything.
    var parser = new DOMParser();

    function load() {
        if (!loading) {
            loading = fetch(box.dataset.index)
                .then(function (r) {
                    if (!r.ok) throw new Error(r.status);
                    return r.json();
                })
                .then(function (data) {
                    pages = data
                        .filter(function (p) { return p.url !== box.dataset.home; })
                        .map(function (p) {
                            return {
                                url: p.url,
                                title: p.title || p.url,
                                text: parser.parseFromString(p.body || "", "text/html").body.textContent.replace(/\s+/g, " ").trim()
                            };
                        });
                });
        }
        return loading;
    }

    function escape(word) {
        return word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }

    function say(text) {
        if (status) status.textContent = text;
    }

    function note(text) {
        say(text);
        var li = document.createElement("li");
        li.className = "search-note";
        li.textContent = text;
        results.appendChild(li);
    }

    // About 180 characters around the first match, cut at spaces, words marked.
    function excerpt(text, words) {
        // Mark the whole word: "micro" marks "microwave".
        var any = new RegExp(WORD_START + "((?:" + words.map(escape).join("|") + ")[\\p{L}\\p{N}]*)", "giu");
        var first = any.exec(text);
        var at = first ? first.index + first[1].length : 0;
        var start = Math.max(0, at - 60);
        var end = Math.min(text.length, start + 180);
        if (start > 0) {
            var space = text.indexOf(" ", start);
            start = space !== -1 && space < at ? space + 1 : at;
        }
        if (end < text.length) {
            var cut = text.lastIndexOf(" ", end);
            if (cut > at) end = cut;
        }
        var part = (start > 0 ? "… " : "") + text.slice(start, end) + (end < text.length ? " …" : "");
        var out = document.createDocumentFragment();
        var from = 0;
        var m;
        any.lastIndex = 0;
        while ((m = any.exec(part)) !== null) {
            var s = m.index + m[1].length;
            out.appendChild(document.createTextNode(part.slice(from, s)));
            var mark = document.createElement("mark");
            mark.textContent = m[2];
            out.appendChild(mark);
            from = s + m[2].length;
        }
        out.appendChild(document.createTextNode(part.slice(from)));
        return out;
    }

    function show() {
        var words = input.value.split(/\s+/).filter(Boolean);
        results.textContent = "";
        results.hidden = words.length === 0;
        if (words.length === 0) return say("");
        var tests = words.map(function (w) { return new RegExp(WORD_START + escape(w), "iu"); });
        var inTitle = [], inText = [];
        pages.forEach(function (p) {
            if (!tests.every(function (t) { return t.test(p.title) || t.test(p.text); })) return;
            (tests.every(function (t) { return t.test(p.title); }) ? inTitle : inText).push(p);
        });
        var hits = inTitle.concat(inText);
        if (hits.length === 0) return note("nothing matches");
        say(hits.length === 1 ? "1 result" : hits.length + " results");
        hits.forEach(function (p) {
            var li = document.createElement("li");
            var a = document.createElement("a");
            a.href = p.url;
            a.textContent = p.title;
            li.appendChild(a);
            if (p.text) {
                var ex = document.createElement("p");
                ex.className = "search-excerpt";
                ex.appendChild(excerpt(p.text, words));
                li.appendChild(ex);
            }
            results.appendChild(li);
        });
    }

    button.addEventListener("click", function () {
        // Just under the navigation, below its last line when it wraps (the button
        // comes first): see --search-top in templates/base.html.
        var top = Math.max(0, Math.round(button.parentNode.getBoundingClientRect().bottom)) + 16;
        dialog.style.setProperty("--search-top", top + "px");
        dialog.showModal();
        input.focus();
        input.select();
    });
    // One Esc closes (a search input spends its first Esc clearing itself); not
    // while an input method is composing.
    input.addEventListener("keydown", function (e) {
        if (e.key === "Escape" && !e.isComposing) {
            e.preventDefault();
            dialog.close();
        }
        // Enter opens the first result.
        if (e.key === "Enter" && !e.isComposing) {
            var first = results.querySelector("a");
            if (first) {
                e.preventDefault();
                first.click();
            }
        }
    });
    // A click on the backdrop closes. The press must start there too, or dragging
    // a selection out of the input would close the box.
    var pressedOutside = false;
    dialog.addEventListener("pointerdown", function (e) { pressedOutside = e.target === dialog; });
    dialog.addEventListener("click", function (e) {
        if (pressedOutside && e.target === dialog) dialog.close();
    });
    input.addEventListener("input", function () {
        load().then(show, function () {
            // Forget the failed fetch, so the next keystroke retries.
            loading = null;
            results.textContent = "";
            results.hidden = false;
            note("search is unavailable: the index did not load");
        });
    });
    button.hidden = false;
})();
EOF
}

render_404_html() {
    cat << 'EOF'
{% extends "base.html" %}

{% block title %}404 | {{ config.title }}{% endblock %}

{% block content %}
<h1 class="section-title">Page not found</h1>
<p>This page isn't here. It may have moved, or the URL may be wrong.</p>
<p>
    <a href="{{ get_url(path='/', trailing_slash=true) }}">Home</a>
</p>
{% endblock %}
EOF
}

render_atom_xml() {
    cat << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"{% if lang %} xml:lang="{{ lang }}"{% endif %}>
    <title>{{ config.title }}
    {%- if term?.name %} - {{ term.name }}
    {%- elif section?.title %} - {{ section.title }}
    {%- endif -%}
    </title>
    {%- if config.description %}
    <subtitle>{{ config.description }}</subtitle>
    {%- endif %}
    <link href="{{ feed_url | safe }}" rel="self" type="application/atom+xml"/>
    <link href="{{ config.base_url | safe }}"/>
    {#- %FT%T%:z, not %+: Tera v2's date filter (jiff) has no %+, and Atom needs
        RFC 3339. -#}
    <generator uri="https://www.getzola.org/">Zola</generator>
    {#- A feed with no dated post has no last_updated; unguarded, the date filter
        stops the build. -#}
    {% if last_updated is defined %}<updated>{{ last_updated | date(format="%FT%T%:z") }}</updated>{% endif %}
    <id>{{ feed_url | safe }}</id>
    {#- RFC 4287: a feed needs an author unless every entry has one, and no post
        here sets one. -#}
    {%- if config.author %}
    <author>
        <name>{{ config.author }}</name>
    </author>
    {%- endif %}
    {%- for page in pages %}
    {%- if page.date %}
    <entry xml:lang="{{ page.lang }}">
        <title>{{ page.title }}</title>
        <published>{{ page.date | date(format="%FT%T%:z") }}</published>
        <updated>{{ (page.updated or page.date) | date(format="%FT%T%:z") }}</updated>
        {%- for author in page.authors %}
        <author>
            <name>{{ author }}</name>
        </author>
        {%- endfor %}
        <link rel="alternate" href="{{ page.permalink | safe }}" type="text/html"/>
        <id>{{ page.permalink | safe }}</id>
        {#- Bare addresses become links, as on the pages. A component's output is
            marked safe, so it's escaped here by hand. -#}
        {%- set body %}{{<linkify html={page.summary or page.content} />}}{% endset %}
        {%- if page.summary %}
        <summary type="html">{{ body | escape_html | safe }}</summary>
        {%- else %}
        <content type="html">{{ body | escape_html | safe }}</content>
        {%- endif %}
    </entry>
    {%- endif %}
    {%- endfor %}
</feed>
EOF
}

# Vars: CI_ZOLA_VERSION, ZOLA_SHA256. \${{...}} and \$ keep Actions and runner
# syntax literal. Actions are pinned by SHA (tag in the trailing comment), Node 24.
render_github_workflow() {
    cat << EOF
name: Deploy to GitHub Pages

on:
  push:
    branches: [main, master]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    # Pinned like the actions and Zola: the last input that could shift.
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          # The build runs code from the repo; it has no use for a git token.
          persist-credentials: false
      - name: Build with Zola ${CI_ZOLA_VERSION} (pinned)
        run: |
          set -euo pipefail
          url="https://github.com/getzola/zola/releases/download/${CI_ZOLA_VERSION}/zola-${CI_ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
          curl -fsSL --proto '=https' --tlsv1.2 -o zola.tar.gz "\$url"
          # The digest setup recorded for these exact bytes. No fallback, no skip.
          echo "${ZOLA_SHA256}  zola.tar.gz" | sha256sum -c -
          tar xzf zola.tar.gz
          # Print Zola's output whether the build passes or fails.
          out=\$(./zola build 2>&1) || { printf '%s\n' "\$out"; exit 1; }
          printf '%s\n' "\$out"
          # As in ./build: a dropped page still goes into sitemap.xml, and zola exits 0.
          if printf '%s\n' "\$out" | grep -q 'page(s) ignored'; then
            echo "::error::pages were dropped but sitemap.xml still lists their URLs; give them a weight, or hidden = true if they shouldn't be listed"
            exit 1
          fi
      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
        with:
          path: public
  deploy:
    needs: build
    # Only this job can publish, so only this job gets the two permissions.
    permissions:
      pages: write
      id-token: write
    runs-on: ubuntu-24.04
    environment:
      name: github-pages
      url: \${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
EOF
}

# =============================================================================
# Entry point (guarded so the file can be sourced to unit-test a render_*)
# =============================================================================
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

Publish a documentation site

This is the guide to the bootstrap-mkdocs script. The script carries the plugin, the theme, the demo pages, the leak checker, the preview command and the CI workflow inside it, and writes them out when you run it. Its first run downloads the theme’s typeface, Charter; see The design.

What it does

The script creates a MkDocs documentation project that builds one set of Markdown sources once per audience: internal, partner, beta and public. Every page says which audiences get it, as in audiences: [public], and you can mark parts of a page with <!-- audience: internal -->...<!-- /audience -->; each build keeps or removes them. Images, PDFs and other files go only into the builds whose pages link to them.

One run does everything: it creates the project, installs it, builds and checks every audience, then starts a live preview and opens it in your browser, the way mdbook serve --open and zola serve --open do once a project exists. Ctrl-C stops the preview.

The filtering is done by a small MkDocs plugin, mkdocs-audience, which the script copies into the project at plugins/mkdocs-audience/. Nothing has to be published first. MkDocs, the Material theme and hatchling, the tool that packages the plugin for installing, come from PyPI. So does fontTools, which corrects a value in Charter’s files that some browsers misread.

This is not access control. The repository holds every audience’s content, so it must stay private. Each build in dist/<audience>/ is a separate static site; deciding who can reach the partner and beta sites is your web host’s job.

Before you start

You need:

  • Python 3.10 or newer. Check with python3 --version. Expected output: Python 3. followed by a minor version of 10 or higher, such as Python 3.11.2.
  • Network access on the first run: to PyPI, for MkDocs, Material, hatchling and fontTools, and to practicaltypography.com, for the Charter font. Later runs don’t need it. If practicaltypography.com can’t be reached, the run still finishes, and the site uses your system’s serif font until a later run adds Charter; see Troubleshooting.
  • Either uv or Python’s venv module.
    • If uv --version prints a version, the script uses uv and needs nothing else.
    • Otherwise it uses python3 -m venv. On Debian and Devuan that needs the python3-venv package. If it’s missing, the script offers to install it; see Troubleshooting.
  • A folder for the project to go in. By default that’s your desktop folder, ~/Desktop, and it must already exist. Check with ls -d ~/Desktop. Expected output: the folder’s full path, such as /home/you/Desktop. On a desktop set up in another language the folder may have another name; see Where the project goes.

Quick start

  1. Save the script as bootstrap-docs.sh, in any folder, for example ~/Downloads. If the file you have is named bootstrap-docs_v32.sh, rename it there:

    mv bootstrap-docs_v32.sh bootstrap-docs.sh
    

    mv prints nothing.

  2. Open bootstrap-docs.sh in a text editor and edit the CONFIG block at the top: PROJECT_NAME if you want a name other than audience-docs, and PROJECT_DIR if the project shouldn’t go on your desktop. Save it. With another name, yours replaces audience-docs in the output and commands below.

  3. Run it from a terminal, in the folder that holds it:

    bash bootstrap-docs.sh
    

    The output starts like this:

    [bootstrap] project: ~/Desktop/audience-docs
    [bootstrap] audiences: internal, partner, beta, public
    [bootstrap] prerequisites: ok (python 3.13.15; uv found, so no python3-venv needed)
    [bootstrap] .venv created with uv
    [bootstrap] wrote plugins/mkdocs-audience/pyproject.toml
    

    Your Python version may differ. Without uv, the third and fourth lines read prerequisites: ok (python 3.x.y; using python3 -m venv) and .venv created with python3 -m venv, and the install line below starts [bootstrap] installed with pip:. It continues with one wrote ... line per plugin file, then one line naming the installed versions (yours may be newer):

    [bootstrap] installed with uv: mkdocs-audience 0.4.1 (from ./plugins), mkdocs 1.6.1, mkdocs-material 9.7.7
    

    Then comes one wrote ... line for each remaining file. Among them, these lines add the font:

    [bootstrap] installed fontTools and brotli in .venv, to correct Charter's files
    [bootstrap] adding Charter: downloading it from practicaltypography.com, checking and correcting it
    [bootstrap] wrote docs/assets/fonts/charter_regular.woff2
    [bootstrap] wrote docs/assets/fonts/charter_italic.woff2
    [bootstrap] wrote docs/assets/fonts/charter_bold.woff2
    [bootstrap] wrote docs/assets/fonts/charter_bold_italic.woff2
    [bootstrap] wrote docs/assets/fonts/LICENSE-Charter.txt
    

    If the download fails, two lines take the place of the last five, and the run carries on:

    [bootstrap] note: couldn't add Charter: couldn't download https://practicaltypography.com/fonts/Charter%20210112.zip (...)
      The site uses your system's serif font until a later run adds Charter.
    

    The text in brackets says why; see Troubleshooting. The list ends with [bootstrap] wrote bin/serve, followed by a count of the files the script owns and the check of every audience:

    [bootstrap] files this script owns: 18 written, 0 updated, 0 already up to date
    [bootstrap] building and checking every audience: bin/check-leaks.py
    [check-leaks] internal: built; 6 pages checked; clean
    [check-leaks] partner: built; 6 pages checked (3 excluded); clean
    [check-leaks] beta: built; 6 pages checked (3 excluded); clean
    [check-leaks] public: built; 6 pages checked (4 excluded); clean
    [check-leaks] all audiences clean
    

    Without the font, the count reads 13 written. Then it starts the preview:

    Done. Starting the internal preview. To start it again later:
    
      cd ~/Desktop/audience-docs
      bin/serve              the internal build, which shows everything
      bin/serve public       the public build (or partner, or beta)
      bin/serve all          every build at one address, as each audience gets it
    
    Before you publish, run bin/check-leaks.py. It rebuilds dist/<audience>/ and
    must end with "all audiences clean".
    
    [serve] internal preview on 127.0.0.1:8000; your browser opens once it's built. Ctrl-C stops it.
    INFO    -  Building documentation...
    INFO    -  Cleaning site directory
    INFO    -  Documentation built in 0.37 seconds
    INFO    -  [15:48:40] Watching paths for changes: 'docs', 'mkdocs.yml'
    INFO    -  [15:48:40] Serving on http://127.0.0.1:8000/ and opening it in a browser
    

    Once your browser has loaded the page, a line INFO - [hh:mm:ss] Browser connected: http://127.0.0.1:8000/ follows. If the run couldn’t add Charter, lines such as WARNING - [hh:mm:ss] "GET /assets/fonts/charter_regular.woff2 HTTP/1.1" code 404 come first, one for each font file the page asks for. They’re harmless, and stop once a later run adds the font. With uv and its packages already cached, the whole run takes a few seconds; with pip, 15 to 30 seconds. On a first run over a slow connection, the install step can take a minute.

  4. Your browser opens at http://127.0.0.1:8000/. You’ll see:

    • a slim purple band across the top that reads “Internal build. Not for public release.”
    • Material’s teal header with your project name, beside a small logo of dark green bars
    • the demo homepage, set in a serif typeface, with three marked blocks: an internal note with a purple rule and the note “Internal only”, a partner note with a blue rule and the note “Partner”, and a beta note with an ochre rule and the note “Beta”
  5. Edit a page under docs/ and save it. The terminal prints Detected file changes, and the browser reloads with your change.

  6. Press Ctrl-C in the terminal to stop the preview. The last line is INFO - Shutting down..., and your shell prompt returns. To start the preview again later:

    cd ~/Desktop/audience-docs
    bin/serve
    

    Expected output: the [serve] line and the INFO lines shown above, and your browser opens again.

The CONFIG block

All settings live at the top of the script. There are no command-line options; anything you put after bash bootstrap-docs.sh is ignored, and the script says so.

SettingDefaultWhat it does
PROJECT_DIR$HOME/DesktopThe folder the project folder goes in. It must exist. See Where the project goes.
PROJECT_NAMEaudience-docsName of the project folder, and the site title. Letters, digits, dots, hyphens and underscores only, not starting with a dot or a hyphen. For a title with spaces, change site_name: in mkdocs.yml and the heading at the top of docs/index.md afterwards.
SITE_URLhttps://example.com/The address the public site will live at. Used for canonical links and the sitemap; change it before you publish.
COPYRIGHTCopyright &copy; 2026Footer text.
REPO_URLemptyYour Git repository’s URL. When set, every build, public included, links to it from the header, so leave this empty while the repository is private.
EDIT_URIedit/main/docs/Path for “Edit this page” buttons. Used only when REPO_URL is set, and the buttons appear only if you also add content.action.edit under theme.features in mkdocs.yml.
WANT_PARTNERtrueInclude the partner audience.
WANT_BETAtrueInclude the beta audience.
WANT_DESIGNtrueThe theme: the Charter typeface, Material’s teal and orange, the green logo, the slim build band and the review marks in the internal build. See The design.
WANT_DEMOtrueDemo pages: API overview, partner guide, beta page, internal runbook.
WANT_EXAMPLEStrueThe marker reference page, built only for internal.
WANT_OVERRIDEStrueThe build banner on internal, partner and beta builds.
WANT_CItrueA GitHub Actions workflow that builds and checks every audience on each push to main and on each pull request.
WANT_LEAK_CHECKtruebin/check-leaks.py, which runs at the end of every run of the script, and bin/serve all, which needs it. Keep this on.
WANT_STRICTtruestrict: true in mkdocs.yml, so warnings fail the build. Keep this on.
WANT_PINtruePin MkDocs and Material to the minor versions installed, in requirements.txt.
WANT_SERVEtrueWhen the run finishes, start the preview of the internal build and open it in your browser. false prints the commands instead.

internal and public are always included. internal is the review build that sees everything; public is the open site.

Each WANT_ setting must be exactly true or false; anything else stops the script with an error naming the setting.

The preview starts only when the script runs in a terminal. If its input or output is redirected, for example in CI or when you pipe it through tee, it prints [bootstrap] note: no terminal (input or output is redirected), so the preview was not started and the commands instead.

To add an audience of your own, such as enterprise, add it to the audiences: list in the generated mkdocs.yml. Then tag the pages it should get, and the pages they link to. With the demo pages, that’s the homepage and the API overview it links to: add enterprise to audiences: in docs/index.md and in docs/api/overview.md. Until a page is tagged for it, its build is empty, and bin/serve all leaves it out. Its band and its marks in the internal build are grey.

Settings for one run

Three settings can go in front of the command, for that run only:

  • WANT_SERVE=false bash bootstrap-docs.sh finishes without starting the preview.
  • REGENERATE_OWNED=true bash bootstrap-docs.sh replaces files the script owns even if you’ve edited them, keeping a copy of each first; see Edits to the script’s files.
  • CHARTER_ZIP="$HOME/Downloads/Charter 210112.zip" bash bootstrap-docs.sh takes the Charter font from a copy of its release you saved yourself, instead of downloading it; the copy has to pass the same checksum. Use the path of the file you saved; a relative path counts from the folder you run the script in.

Every other setting is read from the CONFIG block only.

Where the project goes

The project is created at PROJECT_DIR/PROJECT_NAME: with the defaults, ~/Desktop/audience-docs. The first line of the output names it, as in [bootstrap] project: ~/Desktop/audience-docs.

PROJECT_DIR takes any of these:

  • a path starting with $HOME, as in the default "$HOME/Desktop"
  • a path starting with ~/, as in "~/projects"; the script expands it, though bash doesn’t inside quotes
  • an absolute path, as in "/srv/docs"
  • a relative path, which counts from the folder you run the script in

The folder must exist; the script doesn’t create it. If it doesn’t exist, the script stops before doing anything, with your own path in place of /home/you/Desktop:

[bootstrap] error: PROJECT_DIR (/home/you/Desktop) is not a folder that exists. Create it, or set
  PROJECT_DIR in the CONFIG block to one that does.

Re-running the script

Run the script again at any time, with the same PROJECT_DIR and PROJECT_NAME. It recognises the project from the small .bootstrap-docs file in it, and:

  • reuses the virtual environment, skips installing when the packages are already there, and keeps the font files it added, so it works offline
  • writes any of its files that are missing
  • brings the files it owns up to date with this version of the script
  • checks every audience again, then starts the preview

Your files and the script’s files

The project holds two kinds of files.

Your files the script writes once and never touches again: the pages under docs/, mkdocs.yml, requirements.txt and .gitignore. Edit them freely.

The script’s files it owns, and brings up to date on every run:

  • the plugin, in plugins/mkdocs-audience/
  • bin/serve and bin/check-leaks.py
  • the theme: docs/assets/styles/extra.css, docs/assets/favicon.svg and the files in docs/assets/fonts/
  • the banner template, overrides/main.html
  • the CI workflow, .github/workflows/build.yml

A file whose feature you turn off in the CONFIG block stops being the script’s: it stays where it is, and the script leaves it alone. Turn the feature back on and the script owns the file again.

Edits to the script’s files

Each run records a checksum of every file the script owns in .bootstrap-docs.manifest. Keep that file in Git. The next run compares each owned file with its record before changing anything. If you’ve edited one, the run stops, changes nothing, and names the file:

[bootstrap] these files don't match what this script last wrote there, so they may
[bootstrap] hold your edits, and this run would overwrite them:
    docs/assets/styles/extra.css
[bootstrap] to replace them with this version's files, keeping a copy of each in
[bootstrap] .bootstrap-docs-backup/, run once:
    REGENERATE_OWNED=true bash bootstrap-docs.sh
[bootstrap] to keep an edit instead, move your changes into a file of your own first
[bootstrap] (see the guide). Nothing has been changed.

There are two ways on:

  1. To keep your change, move it into a file of your own, then run the command the message shows to put the script’s file back. For styles, that’s a stylesheet of your own; see Changing the design.
  2. To drop your change, just run the command the message shows.

REGENERATE_OWNED=true copies each file it replaces into .bootstrap-docs-backup/<date>-<time>/ first, at the same path inside that folder. The run then ends its list of files with a line naming the folder, such as [bootstrap] copies of the files it replaced: .bootstrap-docs-backup/20261002-153331/. Git ignores the backup folder, through a .gitignore file inside it. The script never deletes backups; remove the folder yourself once you no longer need it.

A run that stops partway, because the network failed or you pressed Ctrl-C, still records the files it wrote, so the next run doesn’t mistake them for your edits.

What else to know

  • The script writes back any of its files you deleted, demo pages included. Once you’ve removed content you don’t want, turn its setting off in the CONFIG block, for example WANT_DEMO=false and WANT_EXAMPLES=false, before you run the script again. Also delete those pages’ entries under nav: in mkdocs.yml and the links to them in docs/index.md; otherwise the check fails with A reference to '...' is included in the 'nav' configuration, which is not found in the documentation files. Files with no setting, such as docs/index.md, requirements.txt, bin/serve and the plugin’s files, always come back.

  • The check rebuilds each dist/<audience>/ from scratch, deleting everything in it except the hidden files and folders at its top level, whose names start with .. Put files you publish alongside the site, such as CNAME, in docs/. CNAME, robots.txt and favicon.ico go into every build, and any other file only into the builds whose pages link to it (see Files other than pages). Hidden ones, such as .htaccess or .well-known/, are skipped unless you add them to exclude_docs in mkdocs.yml, each with a leading !; then they go into every build. exclude_docs takes lines of text, not a YAML list:

    exclude_docs: |
      !.well-known/
      !.htaccess
    
  • The script won’t take over a folder. If PROJECT_DIR/PROJECT_NAME exists, isn’t empty and wasn’t made by the script, the script stops without changing anything. An existing empty folder is used for the new project.

What gets created

audience-docs/
├── .bootstrap-docs               marks the folder as made by the script
├── .bootstrap-docs.manifest      checksums of the files the script owns; keep it in Git
├── .gitignore                    keeps .venv/, dist/ and site/ out of Git
├── .venv/                        the project's Python environment
├── .github/workflows/build.yml   CI: builds and checks every audience
├── bin/check-leaks.py            builds every audience, fails on any leak or broken link
├── bin/serve                     previews one audience, or all of them
├── dist/                         the builds bin/check-leaks.py made, one per audience
├── mkdocs.yml                    site configuration
├── requirements.txt              what CI, and anyone else, installs
├── overrides/main.html           the build banner
├── plugins/mkdocs-audience/      the plugin itself, with its tests
└── docs/
    ├── index.md                  homepage, shared with every audience
    ├── api/overview.md           shared page with an internal-only block
    ├── integrations/partners.md  partner-only page
    ├── features/beta.md          beta-only page
    ├── operations/runbook.md     internal-only page
    ├── operations/rota.svg       image only the runbook uses, so only internal gets it
    ├── examples/audience-examples.md   marker reference, internal-only
    └── assets/
        ├── favicon.svg           the logo, also the browser-tab icon
        ├── fonts/                Charter, in four files the first run downloads, and its licence
        └── styles/extra.css      the theme

.bootstrap-docs-backup/ appears only after a run with REGENERATE_OWNED=true has replaced something.

How audiences work

Marking content

A block, for content on several lines:

<!-- audience: internal -->
## On-call runbook

Escalation: PagerDuty schedule `prod-oncall`.
<!-- /audience -->

Inline, inside a sentence:

Your API key is <!-- audience: internal -->`sk_live_...` from the vault<!-- /audience --><!-- audience: public -->in your dashboard<!-- /audience -->.

A marker can name several audiences and exclude one with !:

<!-- audience: partner, !beta -->

Markers match in any case, and with any spacing around the word and the colon. They don’t nest: close one before you open the next. Each of these mistakes would let marked text reach every build, so each one stops the build and names the page:

  • an opening marker that’s never closed
  • a closing marker with nothing to close
  • a marker opened inside another
  • a comment that looks like a marker but isn’t one, such as <!-- audiences: internal -->, <!--- audience: internal ---> or <!-- audience internal -->

The message names the page and quotes the marker, for example:

ERROR   -  mkdocs-audience: in api/overview.md, '<!-- audiences: internal -->' looks like an audience marker but isn't one, so the text it was meant to mark would reach every build. Write markers as <!-- audience: NAME --> and <!-- /audience -->.

Page frontmatter

A page’s frontmatter tags the whole page, as a YAML list:

---
audiences: [partner]
---

Every page has to say who gets it. A page that names no audiences stops the build, and the message lists every such page at once:

ERROR   -  mkdocs-audience: 1 page names no audiences, and every page has to say who gets it: notes.md. Add audiences: to each one's frontmatter, as in audiences: [public] for the open site or audiences: [internal] to keep it internal. To build untagged pages for public instead, set untagged: public in the audience plugin's settings in mkdocs.yml.

So a page whose tag you forgot can’t reach the public build. The untagged: line in the plugin’s settings in mkdocs.yml sets this:

  - audience:
      audiences: [internal, partner, beta, public]
      audience: public          # the build you get without MKDOCS_AUDIENCE
      untagged: error           # a page that names no audiences stops the build
  • error, the default, stops the build.
  • internal builds an untagged page for internal only.
  • public builds it for internal and public, as v28 and earlier did.

The plugin finds frontmatter where MkDocs does, and reads the value generously:

  • The block may end with ... instead of ---.
  • MkDocs’s other style works too: audiences: partner on the first line of the page, with no --- lines, ended by a blank line.
  • A comma-separated string, such as audiences: partner, beta, works as well as a list.
  • The plugin matches the key in any case, so Audiences: works.

These mistakes would make a tagged page count as untagged, so each one stops the build and names the page, instead of publishing the page to public:

  • frontmatter that names audiences but isn’t valid YAML, such as audiences: [partner with the ] missing
  • an empty value, such as audiences: []
  • a value that’s neither a list nor a string
  • a negated name, such as audiences: partner, !beta
  • a key that is almost audiences, such as audience or audeinces
  • on a page MkDocs reads no audiences from, a line in its first 60 lines, outside code, that looks like a tag: one in a block below the first line or never closed, one opened with ----, one missing its colon, as in audiences [partner], or one nested under another key

Frontmatter that doesn’t mention audiences is left to MkDocs, so a page can still open with a horizontal rule.

The build stops with a message like this:

ERROR   -  mkdocs-audience: the frontmatter of integrations/partners.md is not valid YAML (line 4): expected ',' or ']', but got '<stream end>'. Fix it; until then the page would be treated as untagged and built for public.

bin/check-leaks.py catches the same mistakes before it builds anything:

[check-leaks] FAILED before building: these pages' tagging can't be trusted, so a build could
[check-leaks] publish them to the wrong audience. Fix them first:
    integrations/partners.md: frontmatter is not valid YAML: expected ',' or ']', but got '<stream end>'

A name that isn’t in the audiences: list in mkdocs.yml prints a warning, which fails strict builds, the check and CI: WARNING - mkdocs_audience: undeclared audience 'partnr' in the frontmatter or file name of integrations/partners.md, followed by the declared names.

Negation with ! works in markers only. In frontmatter, !partner stops the build with a message that says so, in a list or in a string.

Who sees what

  • internal sees everything, unless a marker excludes it with !internal.
  • partner and beta see only what is tagged for them.
  • public sees only pages tagged public, and on them, blocks tagged public and blocks whose marker only excludes others, such as !beta.
  • On any page, text outside markers goes to every build that gets the page.

The marker rules, in order:

  1. An empty marker (<!-- audience: -->) hides its content everywhere.
  2. If the build’s audience is excluded with !, the content is hidden.
  3. The internal build shows everything else.
  4. If the marker names audiences, only those see it.
  5. A marker that only excludes, like !beta, shows to public.

To share a page with every outside audience, tag it audiences: [public, partner, beta]. The demo homepage and API page do exactly that, which is why the partner and beta sites have a homepage.

Linking to restricted pages

A link from a shared page to a page some audiences can’t see would break in their builds, and strict mode fails the build on it. Put such links inside a marker for the audiences that can see the target. The demo homepage shows the pattern:

<!-- audience: partner -->
**Partners:** start with the [Partner integration guide](integrations/partners.md).
It covers keys, webhooks and rate limits.
<!-- /audience -->

A link to a heading inside a marker breaks too, in the builds that leave the heading out. MkDocs only notes that kind of break, so bin/check-leaks.py is what fails on it; see Broken links. Put such a link inside a marker for the same audiences as the heading.

Pages a build excludes are also dropped from that build’s navigation, so their titles never appear in it.

Markers inside code

Markers are left exactly as written inside a fenced code block that starts at the left margin, and inside inline code in single backticks. That’s how the reference page shows them. A closing marker shown in such code doesn’t close the block around it either. In code anywhere else, such as a fence inside a list item, an admonition or a blockquote, or inline code in double backticks, a marker is acted on like any other; see Known limitations.

Files other than pages

An image, a PDF or any other file under docs/ that isn’t a page goes into a build only if a page in that build links to it. The link can be Markdown, such as ![rota](rota.svg), or raw HTML, such as <a href="spec.pdf">, and it counts only if it survives the markers: a link inside an internal block brings its file into the internal build alone. The internal build gets every file.

The demo shows this: only the runbook links to docs/operations/rota.svg, and the runbook is internal, so only dist/internal/ holds the image. Each other build logs the files it left out, as in INFO - mkdocs_audience: left 1 file out of the 'public' build, as no page in it links to it: operations/rota.svg.

These files go into every build whether or not a page links to them:

  • anything under docs/assets/, where the theme’s stylesheet, fonts and logo live
  • CNAME, robots.txt and favicon.ico at the top of docs/
  • a hidden file or folder you let in through exclude_docs, such as .well-known/
  • the stylesheets and scripts mkdocs.yml lists under extra_css and extra_javascript, and the theme’s logo and favicon
  • any file a stylesheet that the build keeps refers to with url() or @import

Put files that templates or scripts use, rather than pages, under docs/assets/, or they’re left out of every build but internal.

An entry under nav: in mkdocs.yml doesn’t count as a link, because every build shares the nav. If one points at a file a build leaves out, such as a PDF no page in that build links to, the build logs a warning, since the entry would be a broken link there:

WARNING -  mkdocs_audience: the nav links to files/spec.pdf, but no page in the 'public' build links to it, so the build leaves it out and the nav entry would be a broken link. Link to it from a page in this build, move it under assets/, which every build gets, or take it out of the nav.

The warning fails strict builds, so bin/check-leaks.py and CI fail on it; the live preview only prints it.

bin/check-leaks.py checks the same rule on the built output: a file copied from docs/ into a partner, beta or public build that none of its pages or stylesheets uses fails the check with file no page in this build links to.

A project made by v28 or later has these lines in mkdocs.yml, so editor backups, and pages saved with an upper-case extension, which MkDocs doesn’t treat as pages, never reach a build:

exclude_docs: |
  *~
  \#*#
  *.bak
  *.orig
  *.MD

If such a file reaches a build anyway, the check fails with page source copied into the build.

Seeing what each group gets

There are three ways to check what each audience will get.

Every build side by side

From the project folder:

bin/serve all

It runs bin/check-leaks.py first, which builds every audience into dist/ and checks each one. Then it serves all of those builds at one address, and opens your browser on a page that lists them:

[serve] building and checking every audience first: bin/check-leaks.py
[check-leaks] internal: built; 6 pages checked; clean
[check-leaks] partner: built; 6 pages checked (3 excluded); clean
[check-leaks] beta: built; 6 pages checked (3 excluded); clean
[check-leaks] public: built; 6 pages checked (4 excluded); clean
[check-leaks] all audiences clean
[serve] internal, partner, beta, public on http://127.0.0.1:8000/; your browser opens to a page listing them. Ctrl-C stops it.

The page has one row per build, each with a rule in its audience’s colour and a line on what that build holds. Click a build to read the site exactly as that audience will get it: these are the files you would publish from dist/<audience>/. To compare one page across audiences, change the build’s name in the address, as in http://127.0.0.1:8000/partner/api/overview/ and http://127.0.0.1:8000/public/api/overview/. A page that a build excludes isn’t there: the server answers with a page that reads Error code: 404. Nor is a file the build left out: http://127.0.0.1:8000/internal/operations/rota.svg shows the demo’s rota image, and http://127.0.0.1:8000/public/operations/rota.svg answers 404.

This view isn’t live. After you edit a page, press Ctrl-C and run bin/serve all again. Ctrl-C prints [serve] stopped.

It serves only the audiences declared in mkdocs.yml, so a folder left in dist/ by an audience you’ve since removed isn’t shown. If the check fails, nothing is served, and the lines above the error say what failed.

One build, live

bin/serve partner

This is the live preview of one build, which reloads as you edit. The band at the top names the build, in its audience’s colour: purple for internal, blue for partner, ochre for beta. The public build has no band, just as on the published site. Two previews can run side by side in two terminals; the second takes the next free port.

Who gets each block

The internal build is the only one that marks content. Every block a marker keeps there has a rule down its left side and a note above it, naming the other audiences that get it. Content no other build gets is noted “Internal only”. The rule takes the colour of the widest audience that gets the block, public over beta over partner:

MarkerRuleNote
<!-- audience: internal -->purpleInternal only
<!-- audience: partner -->bluePartner
<!-- audience: partner, beta -->ochrePartner and beta
<!-- audience: beta, public -->greenBeta and public
<!-- audience: public -->greenPublic
<!-- audience: !beta -->greenPublic
<!-- audience: enterprise -->, for an audience you addedgreyEnterprise

A marker on a line of its own marks the block up to its closing marker, even a single heading. So does a marker around a whole line that’s a heading, a list item, a quote or a table row. A marker inside a line of text marks just that text, with a tint and an underline in the same colour, and its note appears when you point at it.

The partner, beta and public builds carry none of this. Their pages hold the content and nothing about who else gets it. The marks need the theme (WANT_DESIGN=true). Without it, a block’s note stays in the HTML’s data-label attribute and isn’t shown, and an inline mark’s note still appears when you point at it.

Previewing, building and checking

Run these from the project folder. None of them needs the environment activated.

CommandWhat it does
bin/serveLive preview of the internal build. Opens your browser and reloads it when you save. Ctrl-C stops it.
bin/serve publicThe same for any other audience: partner, beta or public.
bin/serve allChecks every audience, then serves every build at one address. See Every build side by side.
bin/check-leaks.pyBuilds every audience in strict mode and verifies each one. Run this before publishing anything.
bin/check-leaks.py --builder properdocsThe same, using ProperDocs instead of MkDocs; see Platform status.
bin/check-leaks.py --no-buildOnly verifies what’s already in dist/<audience>/. Use it after building with any other tool.
MKDOCS_AUDIENCE=public .venv/bin/mkdocs build --site-dir dist/publicBuilds one audience by hand. The output starts with Material’s red notice about MkDocs 2.0, which is harmless (see The red warning notice), and ends with INFO - Documentation built in ... seconds. Without MKDOCS_AUDIENCE, it builds public, from the audience: public line in mkdocs.yml.

The preview

  • It’s private to your computer. bin/serve listens on 127.0.0.1 only, so other machines can’t reach it. Don’t change that to 0.0.0.0: anyone on your network could then read the internal build.
  • It finds a free port. It uses port 8000, or the next free one up to 8020, and says so: [serve] port 8000 is in use (another preview still running?); using 8001.
  • Warnings don’t stop it. A link to a page or file that doesn’t exist prints a WARNING line in the terminal, and the page still updates. Without this, MkDocs in strict mode refuses to start the preview while any warning exists, and freezes an open page until the warning is fixed. bin/check-leaks.py and CI still build in strict mode and fail on every warning.
  • Some broken links don’t print a warning. A link to an anchor that doesn’t exist prints only an INFO line, and a link written in HTML prints nothing. bin/check-leaks.py and CI fail on those too; see Broken links.
  • It watches docs/ and mkdocs.yml. After editing overrides/ or the plugin, stop the preview and start it again.
  • It hides the red warning notice described in The red warning notice.

The check

It reads mkdocs.yml and finds the pages the way MkDocs does, so it checks the audiences and pages MkDocs builds, whatever form the settings take. Before building, it fails if any page’s tagging can’t be trusted, or, with untagged: error, if a page names no audiences; see Page frontmatter. Then, for each audience, it fails if:

  • the build fails in strict mode, for example on a link to a page that doesn’t exist
  • any file in the build holds a marker, or a comment that looks like one, which means a builder that ignored the plugin
  • a page source reached the build as a plain file; see Files other than pages
  • a page excluded for that audience was built
  • any page links to an excluded page, however the link is written, including a full address that starts with SITE_URL
  • the search index lists an excluded page
  • outside internal, the build holds a file from docs/ that none of its pages or stylesheets uses; see Files other than pages
  • a link in the build leads to a page, file or anchor that isn’t in it; see Broken links

It works from the page frontmatter, docs/ and the built files alone, without trusting the plugin. So it also catches a build tool that silently skipped the plugin.

MkDocs checks the links you write in Markdown as it builds. A link to a page or file that isn’t there, including a page the build leaves out, prints a WARNING, which fails strict builds, the check and CI. A link to an anchor that isn’t there prints only an INFO line. So do every absolute link, such as /setup/, and every link to a folder, such as setup/: MkDocs leaves both as written, whether or not they lead anywhere. It doesn’t check links written in HTML, such as <a href="setup.html">, at all.

bin/check-leaks.py then checks every link in each build’s output, however it’s written. Each link has to lead to a page or file in that build, and its anchor, the part after #, to an element with that id on the target page, such as a heading. A link to # or #top goes to the top of the page, so it always passes; MkDocs notes #top as a missing anchor, but the check doesn’t. Full addresses, such as https://example.com/setup/, aren’t checked, even one that starts with SITE_URL, and nor are mailto: and tel: links.

A broken link fails the check in every build that has it. The line under the build names the page the link is on, the link as the built page has it, and what’s missing. With a link to #internal-note added to the demo homepage, outside any marker:

[check-leaks] internal: built; 6 pages checked; clean
[check-leaks] partner: built; 6 pages checked (3 excluded); BROKEN LINKS (1)
    broken anchor: index.md -> #internal-note (no anchor 'internal-note' on this page in this build)
[check-leaks] beta: built; 6 pages checked (3 excluded); BROKEN LINKS (1)
    broken anchor: index.md -> #internal-note (no anchor 'internal-note' on this page in this build)
[check-leaks] public: built; 6 pages checked (4 excluded); BROKEN LINKS (1)
    broken anchor: index.md -> #internal-note (no anchor 'internal-note' on this page in this build)
[check-leaks] FAILED: partner, beta, public. Do not publish these builds.

The heading “Internal note” is inside an internal marker, so only the internal build has the anchor; see Linking to restricted pages.

A Markdown link shows as the address MkDocs wrote into the page, so on the homepage [Overview](api/overview.md#endpoints) shows as api/overview/#endpoints. To have the preview warn you about a missing page while you write, link to pages by their .md file, as in that example, rather than by a folder or an absolute path.

The design

The theme follows four rules:

  1. Material’s teal and orange. The header is Material’s teal, links are its darker teal, and hover and focus are its deep orange. The darker teal is for legibility: the header’s teal is 3.8 to 1 against white, under the 4.5 to 1 that body text needs, and the darker one is 5.3 to 1. The dark scheme keeps Material’s own lighter teal for links.
  2. Audience colours only where an audience is meant: the band at the top and the marks in the internal build. Material’s own signals keep their colours too: the small icon on each kind of note box, and the red and green lines of a diff code block.
  3. Reading first. One serif typeface, Charter, sets everything but code, at about 75 characters a line with generous space between lines. Code uses your system’s monospace font, such as DejaVu Sans Mono.
  4. Hairlines, not boxes. There are no shadows, frames are single hairlines, and pale grey grounds are kept for small things that need setting apart, such as code and the titles of note boxes.

The band is slim: one line of small text. The logo, also the browser-tab icon, is a stack of green bars. On the teal header and menu it’s shown at half brightness, a darker green: the icon’s own green is 1.4 to 1 against the teal and all but disappears, and the darker green is 3.2 to 1. The copyright line is centred at the foot of every page.

The theme follows your system’s light or dark setting. The button in the header switches between following the system, light and dark.

Charter lives in the project, in docs/assets/fonts/: four files, about 61 KB in all, served from your own site, so a page never asks another site for fonts. The script doesn’t carry them. Its first run downloads Charter from Matthew Butterick’s Charter page at practicaltypography.com, the release Homebrew and Arch Linux’s AUR install. It uses the download only if the file’s checksum matches the one both of those record for it, so a changed or damaged file is never used. Later runs keep the files, and Git keeps them with the rest of docs/, so a clone has them too. Until they arrive, pages use the next serif in the stylesheet’s list that your system has, such as Noto Serif or Liberation Serif.

Bitstream’s notice lets anyone use, copy, modify and redistribute Charter, provided the notice stays with the fonts. The script modifies them in one way: it corrects their descender values, which copies of Charter store as positive numbers, so browsers work out the right line height. docs/assets/fonts/LICENSE-Charter.txt holds the notice, the trademark acknowledgement it asks for, and what changed. The stylesheet pins the same values, so an uncorrected copy of Charter still sets its lines right in Chromium and Firefox; on the corrected files they change nothing.

A page printed from the internal, partner or beta build keeps the band’s wording, in black above a black rule, so the paper still says which build it came from.

Changing the design

Don’t edit docs/assets/styles/extra.css: the script owns it, and the next run stops at your edit (see Edits to the script’s files). Put your own rules in a stylesheet of your own instead, listed after extra.css so that its rules win:

  1. Create docs/assets/styles/custom.css with your rules, for example a wider text column:

    .md-content__inner {
      max-width: 36rem;
    }
    
  2. Add it under extra_css in mkdocs.yml, after extra.css:

    extra_css:
      - assets/styles/extra.css
      - assets/styles/custom.css
    

A running preview picks up both changes.

The colours are CSS variables near the top of extra.css: for example --aud-partner is partner’s colour in the light scheme, and --band-partner is its band. To change one, set it again in your own file, on the same selector as in extra.css:

[data-md-color-scheme="default"] {
  --aud-partner: #0b5cad;
}

The header and link colours come from the palette in mkdocs.yml. To use another of Material’s colours, change primary: teal and accent: deep orange in all three palette entries, for example to primary: indigo and accent: amber.

The logo is docs/assets/favicon.svg, which the script owns. To use a logo of your own, add the file, for example docs/assets/logo.svg, and point logo: under theme: in mkdocs.yml at it: logo: assets/logo.svg. It shows as it is: the stylesheet darkens only the file named favicon.svg.

With another primary colour, the darker green can be hard to see; against indigo it’s 1.8 to 1. To show the logo in white instead, add this to your own stylesheet:

.md-logo img[src$="assets/favicon.svg"] {
  filter: brightness(0) invert(1);
}

On another computer

.venv/ stays out of Git, so a fresh clone of your repository has to install the project once:

git clone <your private repository URL> audience-docs
cd audience-docs
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
bin/serve

python3 -m venv .venv prints nothing; on Debian and Devuan it needs the python3-venv package (see Before you start). The pip command lists what it downloads, then prints a line starting Successfully installed; an older pip may add a notice about a newer pip, which you can ignore. With uv instead, run uv venv, then uv pip install -r requirements.txt. uv venv prints three lines, ending with Activate with: source .venv/bin/activate; you don’t need to activate it. uv pip install prints a few progress lines, such as Resolved 30 packages in 9ms and Built mkdocs-audience @ ..., then Installed 30 packages in ... and one line per package. bin/serve then prints the same lines as in the Quick start and opens your browser. The Charter files are in Git with the rest of docs/, so nothing is downloaded.

You can also run the script on the clone. Set PROJECT_DIR to the folder that holds the clone, PROJECT_NAME to the clone’s folder name and the WANT_ settings to match the project, then run bash bootstrap-docs.sh. It recognises the folder from the committed .bootstrap-docs file, checks the script’s files against the committed manifest, installs from requirements.txt so its pins hold, checks every audience and starts the preview.

CI

.github/workflows/build.yml runs on every push to main and on pull requests:

  1. It installs requirements.txt.
  2. It runs bin/check-leaks.py. While WANT_LEAK_CHECK is false, it only builds each audience in strict mode instead, with no leak check and only the link checks MkDocs makes itself (see Broken links); the script rewrites the workflow on every run to match the setting.
  3. It uploads dist/public as a downloadable bundle named public, which GitHub calls an artifact.

It runs with read-only repository permissions, and uses the versions of the GitHub actions that run on Node 24.

Publishing

Copy the contents of dist/<audience>/ to the host for that audience. The rules that matter:

  • Publish only after bin/check-leaks.py prints all audiences clean.
  • Publish only from dist/<audience>/. A plain mkdocs build or mkdocs gh-deploy builds the public site in a project made by v28 or later, because of the audience: public line in mkdocs.yml, but nothing checks what it builds. In a project made by v27 or earlier, which lacks that line, both build the internal site; see Upgrading a project made by v27.
  • Never put dist/internal/ anywhere outsiders can reach it.
  • Keep the repository private. It contains every audience’s content.

Upgrading a project made by v31

Run the script as usual, with the same PROJECT_DIR and PROJECT_NAME. It updates two of its files, bin/check-leaks.py and bin/serve, and checks every audience. Among its lines:

[bootstrap] updated bin/check-leaks.py
[bootstrap] updated bin/serve
[bootstrap] files this script owns: 0 written, 2 updated, 16 already up to date

The check now fails on broken links that v31 passed: an anchor that doesn’t exist, an absolute or folder-style link that leads nowhere, and a broken link written in HTML. If a page has one, the run stops before the preview, with lines like those in Broken links. Fix what they list and run the script again.

If v31 couldn’t add Charter, this run tries again. If the download succeeds, these lines come before bin/check-leaks.py’s line, and the count reads 5 written, 2 updated, 11 already up to date:

[bootstrap] adding Charter: downloading it from practicaltypography.com, checking and correcting it
[bootstrap] wrote docs/assets/fonts/charter_regular.woff2
[bootstrap] wrote docs/assets/fonts/charter_italic.woff2
[bootstrap] wrote docs/assets/fonts/charter_bold.woff2
[bootstrap] wrote docs/assets/fonts/charter_bold_italic.woff2
[bootstrap] wrote docs/assets/fonts/LICENSE-Charter.txt

If it fails again, the note shown in the Quick start appears instead, and the count reads 0 written, 2 updated, 11 already up to date.

Your marker reference page keeps its text; a new project’s says more about links to headings inside markers and about what the check covers.

If you edited one of the script’s files, it stops first; see Edits to the script’s files.

Upgrading a project made by v30

Run the script as usual, with the same PROJECT_DIR and PROJECT_NAME. It updates the plugin’s five files, the stylesheet, bin/check-leaks.py and bin/serve, and checks every audience. Among its lines:

[bootstrap] updated in .venv with uv: mkdocs-audience 0.4.0 -> 0.4.1
[bootstrap] updated docs/assets/styles/extra.css
[bootstrap] updated bin/check-leaks.py
[bootstrap] updated bin/serve
[bootstrap] files this script owns: 0 written, 8 updated, 10 already up to date

Without uv, the first line reads with pip. The logo loses its white tile; nothing in mkdocs.yml needs to change. The check now fails on broken links that v30 passed; see Upgrading a project made by v31.

If v30 couldn’t add Charter, this run tries again. If the download succeeds, these lines come between the stylesheet’s line and bin/check-leaks.py’s, and the count reads 5 written, 8 updated, 5 already up to date:

[bootstrap] adding Charter: downloading it from practicaltypography.com, checking and correcting it
[bootstrap] wrote docs/assets/fonts/charter_regular.woff2
[bootstrap] wrote docs/assets/fonts/charter_italic.woff2
[bootstrap] wrote docs/assets/fonts/charter_bold.woff2
[bootstrap] wrote docs/assets/fonts/charter_bold_italic.woff2
[bootstrap] wrote docs/assets/fonts/LICENSE-Charter.txt

If it fails again, the note shown in the Quick start appears instead, and the count reads 0 written, 8 updated, 5 already up to date.

If you edited one of the script’s files, it stops first; see Edits to the script’s files.

Upgrading a project made by v29

Run the script as usual, with the same PROJECT_DIR and PROJECT_NAME. It keeps the font files, which v29 already corrected, and downloads nothing. It updates the plugin’s five files, the stylesheet, the font licence, bin/check-leaks.py and bin/serve. Among its lines:

[bootstrap] updated in .venv with uv: mkdocs-audience 0.4.0 -> 0.4.1
[bootstrap] updated docs/assets/styles/extra.css
[bootstrap] updated docs/assets/fonts/LICENSE-Charter.txt
[bootstrap] updated bin/check-leaks.py
[bootstrap] updated bin/serve
[bootstrap] files this script owns: 0 written, 9 updated, 9 already up to date

The check now fails on broken links that v29 passed; see Upgrading a project made by v31.

If you edited one of the script’s files, it stops first; see Edits to the script’s files.

Upgrading a project made by v28

Run the script as usual, with the same PROJECT_DIR and PROJECT_NAME:

  1. Run bash bootstrap-docs.sh. It updates the script’s files, including the plugin, the stylesheet and the logo, and checks every audience:

    [bootstrap] updated in .venv with uv: mkdocs-audience 0.3.0 -> 0.4.1
    

    It corrects the font files v28 wrote where they are, without downloading anything, but with fontTools, which it installs from PyPI:

    [bootstrap] installed fontTools and brotli in .venv, to correct Charter's files
    [bootstrap] correcting Charter's files, which v28 wrote uncorrected
    [bootstrap] updated docs/assets/fonts/charter_regular.woff2
    [bootstrap] updated docs/assets/fonts/charter_italic.woff2
    [bootstrap] updated docs/assets/fonts/charter_bold.woff2
    [bootstrap] updated docs/assets/fonts/charter_bold_italic.woff2
    [bootstrap] updated docs/assets/fonts/LICENSE-Charter.txt
    

    followed, further down, by:

    [bootstrap] files this script owns: 0 written, 14 updated, 4 already up to date
    

    Without network access, the fonts stay as v28 wrote them, with a note, and the stylesheet keeps their lines right in Chromium and Firefox until a later run corrects them.

    If you edited one of the script’s files, it stops first; see Edits to the script’s files.

  2. If any of your pages names no audiences, the check stops before building and lists them:

    [check-leaks] FAILED before building: these pages' tagging can't be trusted, so a build could
    [check-leaks] publish them to the wrong audience. Fix them first:
        notes.md: names no audiences; add audiences: to its frontmatter, or set untagged: public in mkdocs.yml
    

    Tag each page listed, then run the script again. To keep v28’s rule instead, under which an untagged page went to internal and public, add untagged: public to the plugin’s settings in mkdocs.yml; see Page frontmatter. The demo pages are all tagged already. The check also fails on broken links that v28 passed; see Upgrading a project made by v31.

  3. Nothing else needs changing. Your mkdocs.yml still says primary: custom and accent: custom, and extra.css gives those Material’s teal and deep orange, so the site looks the same as a new one. To match a new project anyway, change them to primary: teal and accent: deep orange in all three palette entries.

An existing project doesn’t get the demo’s rota image: the script writes it only along with a new runbook page.

Upgrading a project made by v27

v27 put the project in the folder you ran it from, kept no record of its files, and had a different theme. To bring a v27 project up to v32:

  1. Set PROJECT_DIR in the CONFIG block to the folder that holds the project, which is the folder you ran v27 from. For example, if the project is ~/projects/audience-docs, set PROJECT_DIR="~/projects". If you run v32 from that same folder with PROJECT_DIR unchanged, it makes a new project on your desktop instead, and prints a note saying so.

  2. Run bash bootstrap-docs.sh. It stops without changing anything, because v27 kept no record of which of its files you might have edited:

    [bootstrap] this project has no .bootstrap-docs.manifest: an earlier version of this script made
    [bootstrap] it and kept no record of what it wrote, so an edit can't be told from an
    [bootstrap] update. These files are the ones this script owns:
        plugins/mkdocs-audience/pyproject.toml
    

    The list goes on to name every file the script owns in your project.

  3. If you edited any of the listed files, move your changes into files of your own first; see Edits to the script’s files. Then run:

    REGENERATE_OWNED=true bash bootstrap-docs.sh
    

    It copies each file it replaces into .bootstrap-docs-backup/, writes the new theme, downloads its fonts, reinstalls the plugin, and records the script’s files. The plugin line reads [bootstrap] updated in .venv with uv: mkdocs-audience 0.2.0 -> 0.4.1, or with pip without uv. If any page names no audiences, or has a broken link, the check that ends the run stops and lists them; see step 2 of Upgrading a project made by v28 and Broken links. Later runs need no REGENERATE_OWNED. If this run is interrupted, the next plain run stops at the files it hadn’t reached yet; run the forced run again.

  4. The run prints this note, because v27’s mkdocs.yml names no default audience:

    [bootstrap] note: mkdocs.yml names no default audience, so a build without MKDOCS_AUDIENCE
      (a plain mkdocs build, or mkdocs gh-deploy) builds the first audience in
      audiences:, which is internal. Add this line under audiences: in the
      audience plugin's settings, indented the same way:
          audience: public
    

    Add the line yourself; the script never edits mkdocs.yml. The plugin’s settings then read:

      - audience:
          audiences: [internal, partner, beta, public]
          audience: public
    
  5. Optionally, make the rest of mkdocs.yml match a new project:

    • For the green logo in the header, replace the two lines icon: and logo: material/layers-triple under theme: with the one line logo: assets/favicon.svg. Until you do, the header shows Material’s layers icon, in white.
    • Remove - assets/styles/nav-titles.css from extra_css, then delete docs/assets/styles/nav-titles.css, which the script reduces to a comment.
    • Add the exclude_docs lines shown in Files other than pages, and the untagged: error line shown in Page frontmatter, which is also the default without it.

    v27’s palette, primary: teal and accent: deep orange, is already the one a new project has.

  6. Your pages keep their v27 text. The homepage’s internal note and the marker reference page describe v27’s coloured frames, and the reference page says a build without MKDOCS_AUDIENCE is internal, which step 4 changed, and that untagged pages go to public; edit those sentences. If you haven’t changed those two pages otherwise, you can delete them and run the script again to get the current versions.

Platform status

The plugin needs MkDocs 1.x or a fork of it. As of 2 October 2026:

  • MkDocs 1.x: no longer updated. Its last release, 1.6.1, came out on 2024-08-30. It still works, and the project pins it below 2.
  • MkDocs 2.0: can’t run this plugin. It has been in pre-release on PyPI since 2026-08-28; the latest pre-release is 2.0.dev6, from 2026-09-15. It has no plugin system. Its PyPI package declares no licence, though the repository it’s developed in, encode/mkdocs, has carried an MIT licence in docs/LICENSE.md since 2026-09-11. The plugin requires mkdocs<2, so pip install -r requirements.txt never brings in 2.0. A separate pip install --pre --upgrade mkdocs would still replace MkDocs with 2.0: pip reports the conflict, starting with a line ERROR: pip's dependency resolver ..., but still ends with Successfully installed. Builds then stop with Exception: Found mkdocs.yml config, but mkdocs 2.0 pre-release is installed, so nothing leaks.
  • Material for MkDocs: critical and security fixes only. Its authors committed to that “for at least the next 12 months” from 2025-11-05, so to 2026-11-05. The latest release is 9.7.7, from 2026-07-17.
  • ProperDocs: the supported fallback. It’s a drop-in fork of MkDocs 1.x by its former maintainer, and this project builds and checks clean with it. To switch, install it with uv pip install properdocs, or for a project set up without uv, .venv/bin/pip install properdocs. Then check with bin/check-leaks.py --builder properdocs, or build one audience with MKDOCS_AUDIENCE=public .venv/bin/properdocs build -f mkdocs.yml --site-dir dist/public. Without -f mkdocs.yml, ProperDocs prints a notice asking you to rename the file. bin/serve still runs MkDocs.
  • Zensical: unsafe for this project. Zensical is Material’s successor, and it silently ignores plugins it doesn’t list. A build of this project with Zensical 0.0.67, the latest release, prints “No issues found” and publishes every audience’s content, raw markers included. Don’t use it unless bin/check-leaks.py --no-build passes on its output; today it fails. Even then, the check reads only the search index MkDocs writes, so it wouldn’t see a leak through the one Zensical writes, search.json.

The red warning notice

mkdocs build, mkdocs serve and mkdocs gh-deploy start by printing a notice about MkDocs 2.0, headed “Warning from the Material for MkDocs team”, with a red bar down its left side. It’s Material’s notice about the situation above, not an error in your project, and your build continues normally.

bin/serve and bin/check-leaks.py hide it by setting NO_MKDOCS_2_WARNING=true. To hide it for a command you type yourself:

NO_MKDOCS_2_WARNING=true MKDOCS_AUDIENCE=public .venv/bin/mkdocs build --site-dir dist/public

Working on the plugin

The plugin’s source is in plugins/mkdocs-audience/, installed in editable mode. Edits take effect the next time you build; stop and start bin/serve to pick them up. The script owns the plugin’s files, so a change you make there stops the next run of the script until you regenerate them; keep lasting changes in the plugin’s own repository.

To run its tests, from the project folder:

uv pip install pytest            # without uv: .venv/bin/python -m pip install pytest
cd plugins/mkdocs-audience
../../.venv/bin/python -m pytest -q tests/

Expected output ends with a line like 156 passed in 0.78s.

To share the plugin later, put plugins/mkdocs-audience/ in its own Git repository. Others can then install it with pip install git+https://github.com/<you>/mkdocs-audience.

Publishing to PyPI is deliberately not part of this project. If you do it later:

  • PyPI has required two-factor authentication on every account since 2024-01-01.
  • Use Trusted Publishing from GitHub Actions rather than a token stored in ~/.pypirc.

Troubleshooting

run this script with bash: bash bootstrap-docs.sh The script was started with sh or another shell. Run it with bash, as the message shows.

[bootstrap] error: run bootstrap-docs from its saved file (bash bootstrap-docs.sh), not piped from curl The script was piped into bash rather than run from a file. Save it, read its CONFIG block, then run it with bash bootstrap-docs.sh.

[bootstrap] error: PROJECT_DIR (...) is not a folder that exists. Create the folder, or set PROJECT_DIR in the CONFIG block to one that exists; see Where the project goes.

[bootstrap] error: ~/Desktop/audience-docs already exists and was not created by this script. A folder with that name exists, isn’t empty and isn’t the script’s. Nothing was changed. Choose another PROJECT_NAME, or move the folder yourself.

[bootstrap] these files don't match what this script last wrote there You, or another tool, changed a file the script owns, or a run with REGENERATE_OWNED=true was interrupted before it reached the file. See Edits to the script’s files.

[bootstrap] this project has no .bootstrap-docs.manifest The project was made by v27 or earlier, or it’s a copy that lacks the manifest, such as a clone of a repository it was never committed to. See Upgrading a project made by v27.

[bootstrap] note: mkdocs.yml names no default audience Add audience: public to the plugin’s settings in mkdocs.yml, as the note shows.

[bootstrap] note: ./audience-docs here is a project made by bootstrap-docs, but this run works You ran the script in a folder that holds a project of the same name, usually one made by v27 or earlier, which put projects in the folder it ran from. This run works on the project at PROJECT_DIR/PROJECT_NAME and leaves that one alone. To update that project instead, set PROJECT_DIR as the note says and run the script again.

[bootstrap] error: .venv exists but has no working Python (.venv/bin/python). The project’s .venv is damaged. The script won’t delete it, so remove it yourself with the rm -rf command shown in the message, then run the script again.

[bootstrap] missing system packages: python3-venv, followed by run it now? (y/N): Python’s venv support isn’t installed and uv isn’t available. Type y or yes to let the script run the command it shows, sudo apt-get install -y python3-venv; sudo then asks for your password. Any other answer, including just Enter, stops the script and prints the command to run yourself. Installing uv avoids this step entirely.

[bootstrap] error: installing packages failed MkDocs, Material or hatchling couldn’t be downloaded from PyPI. Check your network and run the script again; it picks up where it stopped. Ctrl-C at any point just ends the run, and the next run carries on in the same way.

[bootstrap] note: couldn't add Charter: couldn't download https://practicaltypography.com/fonts/Charter%20210112.zip (...) The run couldn’t reach practicaltypography.com, and the text in brackets says why. Everything else finished, and the site uses your system’s serif font. Until the font arrives, the live preview prints a line such as WARNING - [hh:mm:ss] "GET /assets/fonts/charter_regular.woff2 HTTP/1.1" code 404 for each font file a page asks for; it’s harmless. Run the script again once you have network access; it adds the font, and changes nothing else. The download goes through the proxy your https_proxy setting names, if it names one. If your network blocks the site, download the file at the address the note shows on a computer that can reach it, copy it across, and give the script its path for one run:

CHARTER_ZIP="$HOME/Downloads/Charter 210112.zip" bash bootstrap-docs.sh

Expected output: [bootstrap] adding Charter: reading ~/Downloads/Charter 210112.zip, checking and correcting it, then the four wrote docs/assets/fonts/... lines and the licence’s, as in the Quick start.

... isn't the file this script expects: its sha256 differs The downloaded file, or the one CHARTER_ZIP names, isn’t the Charter release the script was written for, so it wasn’t used, and no font was added. For a download, something between you and the site changed the file, or the site now serves another file at that address. Try again later. If it persists, compare CHARTER_URL and CHARTER_ZIP_SHA256 in the script with the address and checksum in Homebrew’s font-charter cask or the AUR’s ttf-bitstream-charter package. Don’t change CHARTER_ZIP_SHA256 to match the file you got: the checksum is what keeps a changed file out.

[bootstrap] note: couldn't install fontTools, which corrects Charter's files (offline?). PyPI couldn’t be reached on a run that needed fontTools, such as the first run on a project v28 made, or a run with font files to add. Run the script again with network access.

Any other reason after couldn't add Charter: or couldn't correct Charter's files: The fonts failed one of the script’s own checks, so none was written, and the site uses your system’s serif font. With the release the checksum names, that would mean a change in fontTools, whose version the script doesn’t pin.

[bootstrap] error: .venv has no pip, and uv is not installed The project’s .venv has no pip, and Python’s ensurepip couldn’t add it. A run stopped while it was making .venv usually leaves it like that, and the next run adds pip itself, printing [bootstrap] .venv had no pip; added it with ensurepip. If this error appears instead, remove .venv with the rm -rf command the message shows, or install uv, then run the script again.

[bootstrap] error: the leak check failed (see above), so the preview was not started. bin/check-leaks.py found a problem, and the lines above the message name it. Fix it and run the script again. To look at the site while you fix it, run bin/serve in the project folder.

[bootstrap] note: no terminal (input or output is redirected), so the preview was not started The script ran without a terminal, for example from CI or piped through another command. Everything else finished. Start the preview with bin/serve.

No browser opened. Open the address on the Serving on line yourself, or the one on the [serve] line for bin/serve all. Without a desktop session, for example over SSH, no graphical browser can open. If a text-mode browser such as lynx is installed, it opens in the terminal instead, and the live preview doesn’t pick up your edits until you quit it.

[serve] error: ports 8000 to 8020 are all in use. Other previews or programs hold every port the preview tries. Stop a preview with Ctrl-C in its terminal, then run bin/serve again.

[serve] error: .venv/bin/mkdocs not found, so the project isn't installed here. This copy of the project has no environment yet, usually a fresh clone. Run the commands the message shows; see On another computer.

[serve] error: 'bin/serve all' needs bin/check-leaks.py The project was made with WANT_LEAK_CHECK=false. Turn it on in the CONFIG block and run the script again, or preview one build at a time with bin/serve <audience>.

[serve] error: the leak check failed (see above), so nothing was served. bin/serve all serves only checked builds. Fix what the check lists; meanwhile bin/serve internal shows the live site.

ERROR - mkdocs-audience: audience 'pubic' is not declared. The audience given to bin/serve or in MKDOCS_AUDIENCE is misspelt, or missing from audiences: in mkdocs.yml. The message lists the declared names.

ERROR - mkdocs-audience: about a page’s frontmatter or its audiences: line The page’s tagging can’t be trusted, so the build stopped rather than publish the page to public. The message names the page and what to fix; see Page frontmatter.

ERROR - mkdocs-audience: 2 pages name no audiences, or from the check, names no audiences Every page has to say who gets it, and these don’t. Add audiences: to each page listed, or set untagged: in mkdocs.yml; see Page frontmatter.

ERROR - mkdocs-audience: in ... followed by looks like an audience marker, is never closed, has no opening marker or opens inside another one A marker on that page is malformed or unpaired, so the build stopped rather than let the text it marks reach every build. See Marking content.

Checker: page source copied into the build An editor backup, or a page saved with an upper-case extension, reached a build as a plain file. Delete it, or add the exclude_docs lines shown in Files other than pages.

ERROR - mkdocs-audience: the file name of ... looks like the _NAME-page.md convention With filename_convention: true in the plugin’s settings, which is off by default, a file name such as _internal_notes.md is almost a tag; rename it to _internal-notes.md, or to a name without a leading _.

WARNING - Doc file 'X.md' contains a link 'Y.md', but the target is not found among documentation files. A page links to a page this audience can’t see, or to a page that doesn’t exist. When the linking page is in a subfolder of docs/, the warning also quotes the docs-relative path it looked for, as in but the target 'api/nope.md' is not found. In the preview, the warning only prints. In bin/check-leaks.py, CI and strict builds, Aborted with 1 warnings in strict mode! follows and the build fails. Fix the link, or move it inside a marker for the audiences that can see the target; see Linking to restricted pages.

WARNING - mkdocs_audience: undeclared audience 'X' in ... A marker names an audience that isn’t in audiences:, as in undeclared audience 'X' in page.md, or a page’s frontmatter or file name does, as in undeclared audience 'X' in the frontmatter or file name of page.md. Fix the spelling, or add the audience to mkdocs.yml.

WARNING - mkdocs_audience: the nav links to ..., but no page in the '...' build links to it An entry under nav: points at a file that isn’t a page, such as a PDF, and no page in that build links to the file, so the build leaves it out and the entry would be a broken link. Link to the file from a page that build includes, move the file under docs/assets/, or take the entry out of nav:; see Files other than pages.

Checker: file no page in this build links to A partner, beta or public build holds a file from docs/ that none of its pages or stylesheets uses, such as an image only an internal page shows. The plugin leaves such files out, so the build tool skipped the plugin, or the file is one a template or script uses; move that kind of file under docs/assets/. See Files other than pages.

Checker: excluded page was built A page tagged for other audiences ended up in this build. The build tool skipped the plugin, as Zensical does.

Checker: raw audience marker survived A marker has no matching <!-- /audience -->, or the build tool skipped the plugin.

Checker: link to excluded page or search index lists excluded page Something in this build points at a page it shouldn’t know about. The check always builds in strict mode, so MkDocs stops a Markdown link to an excluded page first, as a build failure. The checker’s own messages catch what MkDocs doesn’t check, such as a link written in raw HTML, or output checked with --no-build.

Checker: broken link or broken anchor A link in this build leads to a page, file or anchor that isn’t in it. The line names the page the link is on, the link as the built page has it, and what’s missing: not in this build, no anchor '...' on ... in this build, or, for an absolute link that leaves the folder SITE_URL names, outside the site. Fix the link, or remove it. If the anchor is a heading inside a marker, put the link inside a marker for the same audiences; see Linking to restricted pages.

syntax error: unexpected end of file, or here-document at line ... delimited by end-of-file The script file is incomplete, usually from a download that stopped early. Nothing was created. Download the script again. If only its first few lines arrived, it prints nothing at all; download it again then too.

What changed in v32

What changed in v31 is listed in the v31 guide.

Broken links

  • The check fails on every broken link within the site, not only on the ones MkDocs warns about. In v31, the check, CI and the end of each run passed a link to an anchor that didn’t exist, an absolute or folder-style link that led nowhere, and a broken link written in HTML. MkDocs only notes the first three, at INFO level, and doesn’t check the last. Now the check follows every link in each build’s output to a page or file in that build, and its anchor to an element on the target page; see Broken links. It lists them apart from leaks, under BROKEN LINKS, naming the page each one is on.
  • The guide and bin/serve no longer say every broken link prints a warning in the preview. Only a link to a page or file that doesn’t exist does; the guide now says what the others print.
  • A new project’s marker reference page says that a link to a heading inside a marker breaks in the builds that leave the heading out, and that the check covers links.

Smaller changes

  • The Quick start shows the plugin version a new project gets, 0.4.1, where v31’s guide showed 0.4.0.

Known limitations

  • The stylesheet every build shares names the four default audiences in its colour variables and comments, the public build included. It’s the same file in every project, whatever audiences the project uses, so it says nothing about yours; an audience you add yourself never appears in it.
  • Any edit to a file the script owns stops the next run, even a change of whitespace, until you move the edit out or use REGENERATE_OWNED=true.
  • The theme depends on Material’s class names and colour variables. A Material release that renamed them would spoil parts of the look, though not the filtering or the check.
  • The marker reference page’s syntax examples name partner and beta even in a project without them. The plugin’s warning about undeclared names catches a copied example.
  • REPO_URL puts a link to the repository in the header of every build, including public.
  • The pin in requirements.txt can come out wrong for unusual version numbers. For example, 9.7.0.post1 becomes ~=9.7.0.0. Check the file if a version looks odd.
  • The live preview’s free-port check and the server’s start are separate steps. If another program takes the port in the moment between them, bin/serve stops with OSError: [Errno 98] Address already in use, and bin/serve all with [serve] error: can't serve on 127.0.0.1:8000 (Address already in use). Run bin/serve all again. Run the command again.
  • Every build uses SITE_URL, the public site’s address, for its canonical links and its sitemap, so the internal, partner and beta builds point there too.
  • The file filter sees links in pages and stylesheets only. A file that only a template, a script or a page’s frontmatter uses is left out of the partner, beta and public builds unless it’s under docs/assets/.
  • A file a host or a search engine needs at the top of the site, other than CNAME, robots.txt and favicon.ico, such as a verification page, goes only into the builds with a page that links to it. Link to it from a public page.
  • Material’s deep orange is 2.8 to 1 against white, under the 4.5 to 1 that body text needs. It shows only while the pointer is on a link or a control has focus.
  • The check for tags MkDocs doesn’t read looks at the first 60 lines of a page, outside code; a tag further down isn’t caught.
  • The first run depends on practicaltypography.com. If the site is down, blocked from your network or no longer serves that release, no font is added, and the site uses your system’s serif font; CHARTER_ZIP takes a copy you saved. A newer Charter release needs a newer version of the script, because only the release whose checksum the script carries is used.
  • Font files you put in docs/assets/fonts/ yourself count as edits to the script’s files: the next run stops at them, and a run with REGENERATE_OWNED=true replaces them. To supply Charter yourself, use CHARTER_ZIP.
  • Charter covers Latin script only. Text in other scripts falls back to your system’s serif font.
  • Small gaps in the plugin:
    • Negation with ! doesn’t work in frontmatter; the build stops instead.
    • The plugin recognises code only as a fenced block that starts at the left margin, or inline code in single backticks. A marker inside other code, such as a fence in a list item, an admonition or a blockquote, inline code in double backticks, or code indented four spaces, is acted on. The builds it doesn’t match lose that part of the example, and the internal build shows the marker’s HTML in the code.
  • The check doesn’t follow full addresses. A link written as a full address, such as https://example.com/setup/, isn’t checked, even one that starts with SITE_URL, and nor is a link to another site.
  • ~/Desktop may not exist on a desktop set up in another language, where the folder can have another name. Set PROJECT_DIR to the folder you want.

Bootstrap-mkdocs script

The script creates a MkDocs documentation project that builds one set of Markdown sources once per audience: internal, partner, beta and public. Every page says which audiences get it, as in audiences: [public], and you can mark parts of a page with <!-- audience: internal -->...<!-- /audience -->; each build keeps or removes them. For more details, see Publish a documentation site.

#!/usr/bin/env bash
# bootstrap-docs, v32: create a multi-audience MkDocs documentation project,
# check it, and open a live preview in your browser.
#
# Edit the CONFIG block below, save, then run:
#   bash bootstrap-docs.sh
#
# It creates PROJECT_DIR/PROJECT_NAME and puts everything inside it, builds
# and checks every audience, then serves the internal build on 127.0.0.1 and
# opens it in your browser. Ctrl-C stops the preview; bin/serve starts it
# again later.
#
# Run it again at any time. It adds what's missing and brings the files it
# owns up to date: the plugin, bin/, the theme, the banner template and the
# CI workflow. It never touches docs/ pages, mkdocs.yml or requirements.txt,
# and it stops rather than overwrite a file it owns that you have edited. It
# refuses to use a non-empty folder it didn't create.
#
# The first run needs network access: it installs MkDocs, Material, fontTools
# and the packages they need from PyPI into the project's .venv, and downloads
# the Charter font from practicaltypography.com, which it uses only if it
# matches the checksum below (CHARTER_ZIP_SHA256). Later runs of this version
# work offline.
#
# The mkdocs-audience plugin is part of the project it creates
# (plugins/mkdocs-audience/), so nothing has to be published first.
# Guide: bootstrap-docs-guide_v32.md, which comes with this script.

# Under sh (dash) or zsh, the bash syntax below fails with cryptic errors.
# This test is plain POSIX, so any shell can run it.
if [ -z "${BASH_VERSION:-}" ]; then
    echo "run this script with bash: bash bootstrap-docs.sh" >&2
    exit 2
fi

set -euo pipefail

# Everything below is one block, which bash reads whole before running any of
# it: a copy of this file that was cut off fails with a syntax error and does
# nothing, instead of running part of the script.
{

SCRIPT_VERSION=32

# ===========================================================================
# CONFIG: edit this block, save, then run the script
# ===========================================================================

# Where the project goes: a folder named PROJECT_NAME inside PROJECT_DIR.
# PROJECT_DIR must exist; ~ is allowed. PROJECT_NAME is also the site title.
PROJECT_DIR="$HOME/Desktop"
PROJECT_NAME="audience-docs"
SITE_URL="https://example.com/"
COPYRIGHT="Copyright &copy; 2026"

# Git repository URL. When set, every build, public included, links to it from
# the header, so leave it empty while the repository is private. EDIT_URI is
# used only if you also turn on Material's "Edit this page" buttons
# (content.action.edit under theme.features in mkdocs.yml).
REPO_URL=""
EDIT_URI="edit/main/docs/"

# Audiences. internal and public are always included.
WANT_PARTNER=true
WANT_BETA=true

# Features. All on by default; set any to false to leave it out.
WANT_DESIGN=true          # theme: Charter, teal and orange, green logo, review marks
WANT_DEMO=true            # demo pages: API overview, partner guide, beta page, runbook
WANT_EXAMPLES=true        # marker reference page (internal-only)
WANT_OVERRIDES=true       # build banner on internal, partner and beta builds
WANT_CI=true              # GitHub Actions workflow that builds and checks every audience
WANT_LEAK_CHECK=true      # bin/check-leaks.py: build every audience, fail on any leak
WANT_STRICT=true          # strict: true in mkdocs.yml (warnings fail the build)
WANT_PIN=true             # pin MkDocs and Material minor versions in requirements.txt

# When it's done: serve the internal build and open it in your browser.
# Needs a terminal; Ctrl-C stops it. false: just print how to start it.
# For one run, in front of the command: WANT_SERVE=false bash bootstrap-docs.sh
WANT_SERVE="${WANT_SERVE:-true}"

# ===========================================================================
# END CONFIG
# ===========================================================================

# For one run, in front of the command, to replace files this script owns
# even though you have edited them (a copy of each goes to
# .bootstrap-docs-backup/ first):
#   REGENERATE_OWNED=true bash bootstrap-docs.sh
REGENERATE_OWNED="${REGENERATE_OWNED:-false}"

# For one run, in front of the command, to take the Charter font from a copy
# of its release you saved yourself, instead of downloading it. The checksum
# still applies:
#   CHARTER_ZIP="$HOME/Downloads/Charter 210112.zip" bash bootstrap-docs.sh
CHARTER_ZIP="${CHARTER_ZIP:-}"

# A path with your home folder written as ~, for messages.
shown() {
    case "$1" in
        "$HOME") printf '%s' "~" ;;
        "$HOME"/*) printf '%s' "~${1#"$HOME"}" ;;
        *) printf '%s' "$1" ;;
    esac
}

# Derived from the CONFIG block. Expand a leading ~ (bash doesn't, inside
# quotes) and make the path absolute.
case "$PROJECT_DIR" in
    \~) PROJECT_DIR="$HOME" ;;
    \~/*) PROJECT_DIR="$HOME/${PROJECT_DIR#\~/}" ;;
esac
case "$PROJECT_DIR" in
    /*) ;;
    *) PROJECT_DIR="$PWD/$PROJECT_DIR" ;;
esac
# The same for CHARTER_ZIP, which the run reads from inside the project.
case "$CHARTER_ZIP" in
    \~/*) CHARTER_ZIP="$HOME/${CHARTER_ZIP#\~/}" ;;
    ""|/*) ;;
    *) CHARTER_ZIP="$PWD/$CHARTER_ZIP" ;;
esac
PROJECT_PATH="${PROJECT_DIR%/}/$PROJECT_NAME"
SHOWN_PATH=$(shown "$PROJECT_PATH")

STAMP_FILE=".bootstrap-docs"
MANIFEST=".bootstrap-docs.manifest"
INSTALLER=""
BACKUP_DIR=""
EXISTING=""
OWNED_WRITTEN=0
OWNED_UPDATED=0
OWNED_SAME=0
declare -A OWNED_DONE=()    # owned files this run has written or found up to date

log()  { printf '[bootstrap] %s\n' "$*"; }
note() { printf '[bootstrap] note: %s\n' "$*"; }
die()  { printf '[bootstrap] error: %s\n' "$*" >&2; exit 1; }

# Run from a saved file, not piped from curl: the CONFIG block is meant to be
# read and edited first, and a re-run needs the same file.
if [ ! -f "${BASH_SOURCE[0]:-}" ]; then
    die "run bootstrap-docs from its saved file (bash bootstrap-docs.sh), not piped from curl"
fi
SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"

main() {
    # Ctrl-C ends the run even when the command it interrupts, such as pip,
    # catches it and exits normally.
    trap 'exit 130' INT
    validate_config
    log "project: $SHOWN_PATH"
    log "audiences: $(audience_list ', ')"
    if [ $# -gt 0 ]; then
        note "ignoring arguments ($*); settings live in the CONFIG block at the top of this script"
    fi
    check_prerequisites
    prepare_project_dir
    check_owned_drift
    if [ -n "$EXISTING" ]; then
        log "$SHOWN_PATH was created by bootstrap-docs; adding what's missing and updating the files this script owns"
    fi
    # From here on, however the run ends (an error, Ctrl-C), the manifest
    # records what it wrote, so the next run doesn't mistake that for an edit.
    # A second Ctrl-C can't cut that short.
    trap 'trap "" INT TERM; record_owned' EXIT
    make_venv
    scaffold_plugin
    install_deps
    write_requirements
    scaffold_gitignore
    scaffold_mkdocs_yml
    scaffold_pages
    if $WANT_DESIGN; then scaffold_design; fi
    if $WANT_OVERRIDES; then scaffold_overrides; fi
    if $WANT_LEAK_CHECK; then scaffold_leak_check; fi
    if $WANT_CI; then scaffold_ci; fi
    scaffold_serve
    write_manifest
    finish
}

validate_config() {
    case "$PROJECT_NAME" in
        ""|.*|-*|*[!A-Za-z0-9._-]*)
            die "PROJECT_NAME must be a folder name made of letters, digits, dots, hyphens
  and underscores, not starting with a dot or a hyphen (got '$PROJECT_NAME')" ;;
    esac
    local v
    for v in WANT_PARTNER WANT_BETA WANT_DESIGN WANT_DEMO WANT_EXAMPLES WANT_OVERRIDES \
             WANT_CI WANT_LEAK_CHECK WANT_STRICT WANT_PIN WANT_SERVE REGENERATE_OWNED; do
        case "${!v}" in
            true|false) ;;
            *) die "$v must be true or false (got '${!v}')" ;;
        esac
    done
    if [ ! -d "$PROJECT_DIR" ]; then
        die "PROJECT_DIR ($PROJECT_DIR) is not a folder that exists. Create it, or set
  PROJECT_DIR in the CONFIG block to one that does."
    fi
}

# audience_list SEP: every audience in build order
audience_list() {
    local sep="$1" out="internal"
    if $WANT_PARTNER; then out="${out}${sep}partner"; fi
    if $WANT_BETA; then out="${out}${sep}beta"; fi
    printf '%s' "${out}${sep}public"
}

# external_list: audiences a shared page is tagged for
external_list() {
    local out="public"
    if $WANT_PARTNER; then out="${out}, partner"; fi
    if $WANT_BETA; then out="${out}, beta"; fi
    printf '%s' "$out"
}

# write_once PATH < content: write a file only if it doesn't exist yet
write_once() {
    local path="$1"
    if [ -e "$path" ]; then
        cat >/dev/null
        log "exists, left alone: $path"
        return 0
    fi
    mkdir -p "$(dirname "$path")"
    cat > "$path"
    log "wrote $path"
}

# ---------------------------------------------------------------------------
# Files this script owns. They are written on every run, and the manifest
# records what each run wrote, so the next run can tell an edit from an update.
# ---------------------------------------------------------------------------

# The owned set, given the CONFIG block. A file a run doesn't write isn't
# claimed, so turning a feature off leaves its files to you.
owned_paths() {
    local f
    for f in pyproject.toml README.md LICENSE .gitignore mkdocs_audience/__init__.py \
             mkdocs_audience/plugin.py tests/test_plugin.py; do
        printf '%s\n' "plugins/mkdocs-audience/$f"
    done
    printf '%s\n' bin/serve
    if $WANT_LEAK_CHECK; then printf '%s\n' bin/check-leaks.py; fi
    if $WANT_DESIGN; then
        printf '%s\n' docs/assets/styles/extra.css docs/assets/favicon.svg \
            docs/assets/fonts/charter_regular.woff2 docs/assets/fonts/charter_italic.woff2 \
            docs/assets/fonts/charter_bold.woff2 docs/assets/fonts/charter_bold_italic.woff2 \
            docs/assets/fonts/LICENSE-Charter.txt
        if owns_nav_titles; then
            printf '%s\n' docs/assets/styles/nav-titles.css
        fi
    fi
    if $WANT_OVERRIDES; then printf '%s\n' overrides/main.html; fi
    if $WANT_CI; then printf '%s\n' .github/workflows/build.yml; fi
}

# nav-titles.css was written by v27 and earlier, whose mkdocs.yml still lists
# it. The script owns it in a project one of those versions made, as the
# folder's stamp records; a file of that name in a newer project stays yours.
owns_nav_titles() {
    [ -e docs/assets/styles/nav-titles.css ] || return 1
    local made
    made=$(sed -n 's/^Created by bootstrap-docs v\([0-9][0-9]*\).*/\1/p' "$STAMP_FILE" 2>/dev/null)
    [ -n "$made" ] && [ "$made" -lt 28 ]
}

sha256_of() {
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$1" | cut -d' ' -f1
    else
        python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$1"
    fi
}

# Stop before overwriting a hand edit. Each owned file is compared with what
# the last run wrote (the manifest), not with what this run would write, so a
# changed setting or a newer script never looks like an edit. A project with
# no manifest was made by v27 or earlier, which kept no record: every owned
# file there counts as edited. Runs before anything is installed or written,
# so "nothing has been changed" is true.
check_owned_drift() {
    if $REGENERATE_OWNED; then
        return 0
    fi
    local path recorded no_manifest="" drifted=()
    if [ ! -f "$MANIFEST" ]; then
        no_manifest=1
    fi
    while IFS= read -r path; do
        # A missing file isn't an edit, nor is a Charter file as v28 wrote
        # it: the run corrects it without losing anything.
        if [ ! -f "$path" ] || charter_uncorrected "$path"; then
            continue
        fi
        if [ -n "$no_manifest" ]; then
            drifted+=("$path")
            continue
        fi
        recorded=$(recorded_sha256 "$path")
        if [ -z "$recorded" ] || [ "$(sha256_of "$path")" != "$recorded" ]; then
            drifted+=("$path")
        fi
    done < <(owned_paths)
    if [ ${#drifted[@]} -eq 0 ]; then
        return 0
    fi
    {
        echo
        if [ -n "$no_manifest" ]; then
            log "this project has no $MANIFEST: an earlier version of this script made"
            log "it and kept no record of what it wrote, so an edit can't be told from an"
            log "update. These files are the ones this script owns:"
        else
            log "these files don't match what this script last wrote there, so they may"
            log "hold your edits, and this run would overwrite them:"
        fi
        printf '    %s\n' "${drifted[@]}"
        log "to replace them with this version's files, keeping a copy of each in"
        log ".bootstrap-docs-backup/, run once:"
        printf '    REGENERATE_OWNED=true bash %s\n' "$SCRIPT_NAME"
        log "to keep an edit instead, move your changes into a file of your own first"
        log "(see the guide). Nothing has been changed."
    } >&2
    exit 1
}

# Keep a copy of an owned file a forced run is about to replace.
backup_owned() {
    local path="$1"
    if [ -z "$BACKUP_DIR" ]; then
        BACKUP_DIR=".bootstrap-docs-backup/$(date +%Y%m%d-%H%M%S)"
        mkdir -p "$BACKUP_DIR"
        # Git ignores the folder from inside, whatever your .gitignore says.
        if [ ! -f .bootstrap-docs-backup/.gitignore ]; then
            printf '*\n' > .bootstrap-docs-backup/.gitignore
        fi
    fi
    mkdir -p "$BACKUP_DIR/$(dirname "$path")"
    cp -p "$path" "$BACKUP_DIR/$path"
}

# put_owned PATH COMMAND...: write COMMAND's output to PATH, unless PATH holds it already.
# A file counts as this run's from the moment its old content is safe: the
# drift check found it unedited, or a forced run has backed it up. So a run
# cut off while writing it records it, and the next run rewrites it.
put_owned() {
    local path="$1"
    shift
    if [ -f "$path" ] && cmp -s "$path" <("$@"); then
        OWNED_DONE["$path"]=1
        OWNED_SAME=$((OWNED_SAME + 1))
    elif [ -e "$path" ]; then
        if $REGENERATE_OWNED; then
            backup_owned "$path"
        fi
        OWNED_DONE["$path"]=1
        "$@" > "$path"
        OWNED_UPDATED=$((OWNED_UPDATED + 1))
        log "updated $path"
    else
        mkdir -p "$(dirname "$path")"
        OWNED_DONE["$path"]=1
        "$@" > "$path"
        OWNED_WRITTEN=$((OWNED_WRITTEN + 1))
        log "wrote $path"
    fi
}

# write_owned PATH < text
write_owned() {
    local content
    content=$(cat; printf x)
    put_owned "$1" printf '%s' "${content%x}"
}

# The recorded sha256 of PATH in the manifest; empty if it has none.
recorded_sha256() {
    if [ -f "$MANIFEST" ]; then
        awk -v p="$1" '$2 == p { print $1; exit }' "$MANIFEST"
    fi
}

# Write the manifest. An owned file this run has written, or found up to date,
# is recorded as it is now. One it hasn't reached yet keeps its old record, so
# a run that stops early leaves a record the next run can trust. A file no
# longer owned (its feature turned off) keeps its record too, so turning the
# feature back on can still tell an edit from an update.
record_owned() {
    local path recorded lines=() owned=()
    local -A is_owned=()
    mapfile -t owned < <(owned_paths)
    for path in "${owned[@]}"; do
        is_owned["$path"]=1
        if [ -n "${OWNED_DONE[$path]:-}" ] && [ -f "$path" ]; then
            lines+=("$(sha256_of "$path")  $path")
        else
            recorded=$(recorded_sha256 "$path")
            if [ -n "$recorded" ]; then
                lines+=("$recorded  $path")
            fi
        fi
    done
    if [ -f "$MANIFEST" ]; then
        while read -r recorded path; do
            case "$recorded" in "#"*|"") continue ;; esac
            if [ -f "$path" ] && [ -z "${is_owned[$path]:-}" ]; then
                lines+=("$recorded  $path")
                is_owned["$path"]=1
            fi
        done < "$MANIFEST"
    fi
    {
        echo "# Written by bootstrap-docs: the sha256 and path of each file it owns. Keep it in Git."
        if [ ${#lines[@]} -gt 0 ]; then
            printf '%s\n' "${lines[@]}"
        fi
    } > "$MANIFEST.new"
    mv -f "$MANIFEST.new" "$MANIFEST"
}

# Record what this run left on disk, once every owned file is in place.
write_manifest() {
    record_owned
    log "files this script owns: $OWNED_WRITTEN written, $OWNED_UPDATED updated, $OWNED_SAME already up to date"
    if [ -n "$BACKUP_DIR" ]; then
        log "copies of the files it replaced: $BACKUP_DIR/"
    fi
}

check_prerequisites() {
    local missing=()
    command -v python3 >/dev/null 2>&1 || missing+=(python3)
    if ! command -v uv >/dev/null 2>&1; then
        # Without uv, Python's built-in venv needs ensurepip, which Debian and
        # Devuan ship in the python3-venv package.
        python3 -m ensurepip --version >/dev/null 2>&1 || missing+=(python3-venv)
    fi
    if [ ${#missing[@]} -gt 0 ]; then
        offer_system_install "${missing[@]}"
    fi
    python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' \
        || die "Python 3.10 or newer is required (found $(python3 --version 2>&1))"
    local pyver
    pyver=$(python3 -c 'import platform; print(platform.python_version())')
    if command -v uv >/dev/null 2>&1; then
        log "prerequisites: ok (python $pyver; uv found, so no python3-venv needed)"
    else
        log "prerequisites: ok (python $pyver; using python3 -m venv)"
    fi
}

# Offer to install missing system packages with sudo apt-get. Asks first;
# the default answer is no. Never runs without a terminal to ask on.
offer_system_install() {
    local pkgs=("$@") apt_pkgs=() p
    for p in "${pkgs[@]}"; do
        case "$p" in
            python3) apt_pkgs+=(python3-full) ;;
            *) apt_pkgs+=("$p") ;;
        esac
    done
    local cmd=(sudo apt-get install -y "${apt_pkgs[@]}")
    if ! command -v apt-get >/dev/null 2>&1; then
        die "missing system packages: ${pkgs[*]}
  This system has no apt-get. Install the equivalents with your package
  manager, then re-run this script."
    fi
    if ! command -v sudo >/dev/null 2>&1; then
        die "missing system packages: ${pkgs[*]}
  sudo is not installed. As root, run:  apt-get install -y ${apt_pkgs[*]}
  Then re-run this script."
    fi
    printf '\n[bootstrap] missing system packages: %s\n' "${pkgs[*]}"
    printf '[bootstrap] to install them, this script would run:\n    %s\n' "${cmd[*]}"
    printf '[bootstrap] sudo will ask for your password.\n\n'
    if [ ! -t 0 ]; then
        die "no terminal to ask on. Run it yourself:  ${cmd[*]}
  Then re-run this script."
    fi
    local reply
    read -r -p "[bootstrap] run it now? (y/N): " reply
    case "$reply" in
        y|Y|yes|YES) ;;
        *) die "declined. Run it yourself:  ${cmd[*]}
  Then re-run this script." ;;
    esac
    log "running: ${cmd[*]}"
    "${cmd[@]}" || die "the install failed (see the output above). Common causes: no network,
  another package manager holding the apt lock, or a wrong password.
  Fix that, then re-run this script."
    local still=()
    command -v python3 >/dev/null 2>&1 || still+=(python3)
    if ! command -v uv >/dev/null 2>&1; then
        python3 -m ensurepip --version >/dev/null 2>&1 || still+=(python3-venv)
    fi
    if [ ${#still[@]} -gt 0 ]; then
        die "the install finished but these are still missing: ${still[*]}
  Try:  sudo apt-get install --reinstall ${apt_pkgs[*]}"
    fi
    log "system packages installed"
}

prepare_project_dir() {
    if [ -e "$PROJECT_PATH" ] && [ ! -d "$PROJECT_PATH" ]; then
        die "'$SHOWN_PATH' exists and is not a folder. Change PROJECT_NAME or PROJECT_DIR in the CONFIG block."
    fi
    if [ -d "$PROJECT_PATH" ] && [ ! -f "$PROJECT_PATH/$STAMP_FILE" ] \
       && [ -n "$(ls -A "$PROJECT_PATH" 2>/dev/null)" ]; then
        die "$SHOWN_PATH already exists and was not created by this script.
  Nothing was changed. Choose another PROJECT_NAME in the CONFIG block, or
  move that folder out of the way yourself."
    fi
    if [ -f "$PWD/$PROJECT_NAME/$STAMP_FILE" ] && [ "$PWD/$PROJECT_NAME" != "$PROJECT_PATH" ]; then
        # v27 and earlier made the project in the folder they ran from.
        note "./$PROJECT_NAME here is a project made by bootstrap-docs, but this run works
  on the one at $SHOWN_PATH. To work on ./$PROJECT_NAME instead, set
  PROJECT_DIR=\"$(shown "$PWD")\" in the CONFIG block and run this script again."
    fi
    if [ -f "$PROJECT_PATH/$STAMP_FILE" ]; then
        EXISTING=1
    fi
    mkdir -p "$PROJECT_PATH"
    cd "$PROJECT_PATH"
    if [ ! -f "$STAMP_FILE" ]; then
        printf 'Created by bootstrap-docs v%s. Re-runs use this file to recognise the folder.\n' \
            "$SCRIPT_VERSION" > "$STAMP_FILE"
    fi
}

# Create or reuse .venv. Never deletes anything.
make_venv() {
    if [ -e .venv ]; then
        if [ -x .venv/bin/python ] && .venv/bin/python -c 'import sys' >/dev/null 2>&1; then
            log ".venv exists and works; reusing it"
        else
            die ".venv exists but has no working Python (.venv/bin/python).
  This script won't delete it. If you don't need that folder, remove it
  yourself and re-run:
    rm -rf \"$PWD/.venv\""
        fi
    elif command -v uv >/dev/null 2>&1; then
        uv venv --quiet --python python3 .venv
        log ".venv created with uv"
    else
        python3 -m venv .venv
        log ".venv created with python3 -m venv"
    fi
}

# uv when available; otherwise the venv's own pip.
pick_installer() {
    if command -v uv >/dev/null 2>&1; then
        INSTALLER=uv
    elif .venv/bin/python -m pip --version >/dev/null 2>&1; then
        INSTALLER=pip
    elif .venv/bin/python -m ensurepip --upgrade --default-pip >/dev/null 2>&1 \
         && .venv/bin/python -m pip --version >/dev/null 2>&1; then
        # A run stopped while python3 -m venv was making .venv leaves it without pip.
        log ".venv had no pip; added it with ensurepip"
        INSTALLER=pip
    else
        die ".venv has no pip, and uv is not installed, so nothing can be installed into it.
  Install uv, or remove .venv yourself and re-run so it is recreated with pip:
    rm -rf \"$PWD/.venv\""
    fi
}

venv_install() {
    if [ "$INSTALLER" = uv ]; then
        uv pip install --quiet --python .venv/bin/python "$@"
    else
        .venv/bin/python -m pip install --quiet "$@"
    fi
}

pkg_version() {
    .venv/bin/python -c 'import importlib.metadata as m, sys; print(m.version(sys.argv[1]))' "$1" 2>/dev/null \
        || printf '?'
}

# Install the plugin, MkDocs and Material, unless they're already in .venv.
# Skipping the install is what lets a re-run work offline. A plugin whose
# version changed is installed again, so pip reports the version in use.
install_deps() {
    local plugin mk mat want
    plugin=$(pkg_version mkdocs-audience)
    mk=$(pkg_version mkdocs)
    mat=$(pkg_version mkdocs-material)
    want=$(sed -n 's/^version = "\(.*\)"$/\1/p' plugins/mkdocs-audience/pyproject.toml)
    if [ "$plugin" != "?" ] && [ "$mk" != "?" ] && [ "$mat" != "?" ]; then
        if [ "$plugin" = "$want" ]; then
            log "already installed in .venv: mkdocs-audience $plugin, mkdocs $mk, mkdocs-material $mat"
            return 0
        fi
        pick_installer
        if venv_install -e ./plugins/mkdocs-audience; then
            log "updated in .venv with $INSTALLER: mkdocs-audience $plugin -> $(pkg_version mkdocs-audience)"
        else
            note "could not reinstall the plugin (offline?). It is installed in editable mode, so
  version $want's code is in use already; only the version pip reports stays $plugin."
        fi
        return 0
    fi
    pick_installer
    if [ "$INSTALLER" = pip ]; then
        .venv/bin/python -m pip install --quiet --upgrade pip
    fi
    # A project that has a requirements.txt, such as a fresh clone, installs
    # from it, so its pins hold.
    local spec=(-e ./plugins/mkdocs-audience mkdocs-material)
    if [ -f requirements.txt ]; then
        spec=(-r requirements.txt)
    fi
    if ! venv_install "${spec[@]}"; then
        echo >&2
        die "installing packages failed (see the output above). The plugin installs from
  ./plugins/mkdocs-audience; MkDocs and Material come from PyPI, so check your network,
  then run this script again."
    fi
    log "installed with $INSTALLER: mkdocs-audience $(pkg_version mkdocs-audience) (from ./plugins), mkdocs $(pkg_version mkdocs), mkdocs-material $(pkg_version mkdocs-material)"
}

write_requirements() {
    if [ -e requirements.txt ]; then
        log "exists, left alone: requirements.txt"
        return 0
    fi
    local mk mat
    mk=$(pkg_version mkdocs)
    mat=$(pkg_version mkdocs-material)
    {
        echo "# Install with:  pip install -r requirements.txt   (or: uv pip install -r requirements.txt)"
        echo "# The audience plugin is part of this project: ./plugins/mkdocs-audience"
        if $WANT_PIN; then
            echo "# MkDocs and Material are pinned to the minor versions bootstrap-docs installed;"
            echo "# patch releases still apply. Project pages:"
            echo "#   https://pypi.org/project/mkdocs/"
            echo "#   https://pypi.org/project/mkdocs-material/"
            echo "mkdocs~=${mk%.*}.0"
            echo "mkdocs-material~=${mat%.*}.0"
        else
            echo "mkdocs<2"
            echo "mkdocs-material"
        fi
        echo "-e ./plugins/mkdocs-audience"
    } > requirements.txt
    log "wrote requirements.txt"
}

scaffold_gitignore() {
    write_once .gitignore <<'BD_GITIGNORE_EOF'
.venv/
dist/
site/
__pycache__/
*.egg-info/
.pytest_cache/
BD_GITIGNORE_EOF
}

scaffold_mkdocs_yml() {
    if [ -e mkdocs.yml ]; then
        log "exists, left alone: mkdocs.yml"
        # Projects made by v27 and earlier have no default audience, so a
        # build without MKDOCS_AUDIENCE builds the first one listed: internal.
        if ! grep -Eq '^[[:space:]]+audience:[[:space:]]*["'\'']?[A-Za-z0-9_-]' mkdocs.yml; then
            note "mkdocs.yml names no default audience, so a build without MKDOCS_AUDIENCE
  (a plain mkdocs build, or mkdocs gh-deploy) builds the first audience in
  audiences:, which is internal. Add this line under audiences: in the
  audience plugin's settings, indented the same way:
      audience: public"
        fi
        return 0
    fi
    local repo_block custom_dir look_block css_block nav_block strict
    if [ -n "$REPO_URL" ]; then
        repo_block="repo_url: $REPO_URL
edit_uri: $EDIT_URI"
    else
        repo_block="# repo_url links the repository from the header of every build, public included.
# \"Edit this page\" buttons also need content.action.edit under theme.features.
# repo_url: https://github.com/your-org/your-repo
# edit_uri: $EDIT_URI"
    fi
    custom_dir=""
    if $WANT_OVERRIDES; then custom_dir="
  custom_dir: overrides"; fi
    look_block=""
    if $WANT_DESIGN; then
        look_block="
  logo: assets/favicon.svg
  favicon: assets/favicon.svg"
        css_block="extra_css:
  - assets/styles/extra.css"
    else
        css_block="# extra_css:
#   - assets/styles/your-styles.css"
    fi
    nav_block="nav:
  - Home: index.md"
    if $WANT_DEMO; then
        nav_block="${nav_block}
  - API:
    - Overview: api/overview.md"
        if $WANT_PARTNER; then nav_block="${nav_block}
  - Integrations:
    - Partners: integrations/partners.md"; fi
        if $WANT_BETA; then nav_block="${nav_block}
  - Features:
    - Beta: features/beta.md"; fi
        nav_block="${nav_block}
  - Operations:
    - Runbook: operations/runbook.md"
    fi
    if $WANT_EXAMPLES; then nav_block="${nav_block}
  - Reference:
    - Audience examples: examples/audience-examples.md"; fi
    strict="strict: false"
    if $WANT_STRICT; then strict="strict: true"; fi

    cat > mkdocs.yml <<EOF
site_name: "$PROJECT_NAME"
site_url: $SITE_URL
copyright: "$COPYRIGHT"

$repo_block

$strict

theme:
  name: material${custom_dir}
  font: false               # never load Google Fonts${look_block}
  features:
    - navigation.footer
    - navigation.expand
    - navigation.instant
    - navigation.tracking
    - content.code.copy
    - search.highlight
    - search.suggest
  palette:
    - media: "(prefers-color-scheme)"
      toggle:
        icon: material/brightness-auto
        name: Switch to light mode
      primary: teal
      accent: deep orange
    - media: "(prefers-color-scheme: light)"
      scheme: default
      toggle:
        icon: material/brightness-7
        name: Switch to dark mode
      primary: teal
      accent: deep orange
    - media: "(prefers-color-scheme: dark)"
      scheme: slate
      toggle:
        icon: material/brightness-4
        name: Switch to system preference
      primary: teal
      accent: deep orange

plugins:
  - search
  - audience:
      audiences: [$(audience_list ', ')]
      audience: public          # the build you get without MKDOCS_AUDIENCE
      untagged: error           # a page that names no audiences stops the build

markdown_extensions:
  - attr_list
  - md_in_html
  - admonition
  - tables
  - def_list
  - pymdownx.smartsymbols
  - pymdownx.superfences
  - pymdownx.tabbed:
      alternate_style: true
  - pymdownx.highlight:
      anchor_linenums: true
  - toc:
      permalink: true
      permalink_title: Anchor link to this section
      toc_depth: 4

# Never copy editor backups, or pages saved with an upper-case extension, into
# a build: MkDocs doesn't treat them as pages, so it would publish them as they
# are. Add your own lines; see the guide.
exclude_docs: |
  *~
  \#*#
  *.bak
  *.orig
  *.MD

$css_block

$nav_block
EOF
    log "wrote mkdocs.yml"
}

scaffold_index() {
    if [ -e docs/index.md ]; then
        log "exists, left alone: docs/index.md"
        return 0
    fi
    mkdir -p docs
    {
        printf -- '---\naudiences: [%s]\n---\n\n' "$(external_list)"
        printf '# %s\n\n' "$PROJECT_NAME"
        echo "Welcome."
        echo "This site is built from one set of Markdown sources, once per audience."
        if $WANT_DEMO; then
            echo "Start with the [API overview](api/overview.md)."
        fi
        echo
        echo "<!-- audience: internal -->"
        echo "## Internal note"
        echo
        echo "Only the internal build shows this block."
        if $WANT_DESIGN; then
            echo "It is also the only build that marks content: the rule beside each marked block, and the note above it, say which other builds include it."
        fi
        if $WANT_EXAMPLES; then
            echo "Authoring reference: [Audience examples](examples/audience-examples.md)."
        fi
        if $WANT_DEMO; then
            echo "On-call procedures: [Operations runbook](operations/runbook.md)."
        fi
        echo "<!-- /audience -->"
        if $WANT_DEMO && $WANT_PARTNER; then
            echo
            echo "<!-- audience: partner -->"
            echo "**Partners:** start with the [Partner integration guide](integrations/partners.md)."
            echo "It covers keys, webhooks and rate limits."
            echo "<!-- /audience -->"
        fi
        if $WANT_DEMO && $WANT_BETA; then
            echo
            echo "<!-- audience: beta -->"
            echo "**Beta testers:** see [Beta features](features/beta.md)."
            echo "Expect changes before general release."
            echo "<!-- /audience -->"
        fi
    } > docs/index.md
    log "wrote docs/index.md"
}

scaffold_pages() {
    scaffold_index
    if $WANT_DEMO; then
        if [ -e docs/api/overview.md ]; then
            log "exists, left alone: docs/api/overview.md"
        else
            mkdir -p docs/api
            {
                printf -- '---\naudiences: [%s]\n---\n\n' "$(external_list)"
                cat <<'BD_API_EOF'
# API Overview

Public endpoints go here.

`GET /v1/users/{id}` returns user metadata.

<!-- audience: internal -->
## Internal: debug endpoints

Not exposed publicly.

`GET /_debug/health` returns dependency status.
<!-- /audience -->
BD_API_EOF
            } > docs/api/overview.md
            log "wrote docs/api/overview.md"
        fi
        if $WANT_PARTNER; then
        write_once docs/integrations/partners.md <<'BD_PARTNER_EOF'
---
title: Partner Integration Guide
audiences: [partner]
---

# Partner Integration Guide

Tagged `audiences: [partner]`: built only for partner (and internal).
Replace with real partner content.
BD_PARTNER_EOF
        fi
        if $WANT_BETA; then
        write_once docs/features/beta.md <<'BD_BETA_EOF'
---
title: Beta Features
audiences: [beta]
---

# Beta Features

Tagged `audiences: [beta]`: built only for beta (and internal).
Replace with real beta content.
BD_BETA_EOF
        fi
        if [ -e docs/operations/runbook.md ]; then
            log "exists, left alone: docs/operations/runbook.md"
        else
            write_once docs/operations/runbook.md <<'BD_RUNBOOK_EOF'
---
title: Operations Runbook
audiences: [internal]
---

# Operations Runbook

Tagged `audiences: [internal]`: built only for internal.
Replace with real internal content.

![On-call rota](rota.svg)

Only this page links to the rota image, `operations/rota.svg`, so only the internal build includes it.
Every other build leaves it out.
BD_RUNBOOK_EOF
            write_once docs/operations/rota.svg <<'BD_ROTA_EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="72" viewBox="0 0 320 72">
  <rect x="0.5" y="0.5" width="319" height="71" rx="4" fill="#ffffff" stroke="#737881"/>
  <text x="16" y="30" font-family="Charter, Georgia, serif" font-size="16" fill="#1d1e20">On-call rota</text>
  <text x="16" y="52" font-family="Charter, Georgia, serif" font-size="13" fill="#4a4e55">This week: primary A, secondary B</text>
</svg>
BD_ROTA_EOF
        fi
    fi
    if $WANT_EXAMPLES; then
        write_once docs/examples/audience-examples.md < <(examples_page)
    fi
}

# The marker reference page. What it says about this project's builds follows
# the CONFIG block; the syntax examples use the default audience names.
examples_page() {
    cat <<'BD_EXAMPLES_HEAD_EOF'
---
audiences: [internal]
---

# Audience examples

The authoring reference for this project's audience markers.
This page is tagged `audiences: [internal]`, so only the internal build includes it.

## How builds work

Each build targets one audience.
To preview one in your browser, with live reload:

```bash
bin/serve public
```

BD_EXAMPLES_HEAD_EOF
    if $WANT_LEAK_CHECK; then
        cat <<'BD_EXAMPLES_ALL_EOF'
To see every build side by side, as each audience will get it:

```bash
bin/serve all
```

BD_EXAMPLES_ALL_EOF
    fi
    cat <<'BD_EXAMPLES_BUILD_EOF'
To build one into a folder, choose the audience with the `MKDOCS_AUDIENCE` variable:

```bash
MKDOCS_AUDIENCE=public .venv/bin/mkdocs build --site-dir dist/public
```

Without the variable, this project builds the public site.

BD_EXAMPLES_BUILD_EOF
    echo "- \`internal\` sees everything, unless a marker excludes it with \`!internal\`."
    if $WANT_DESIGN; then
        echo "  In the internal build, every marked block has a rule beside it and a note above it naming the other audiences that get it, or \"Internal only\"."
    fi
    if $WANT_PARTNER && $WANT_BETA; then
        echo "- \`partner\` and \`beta\` see only pages and blocks tagged for them."
    elif $WANT_PARTNER; then
        echo "- \`partner\` sees only pages and blocks tagged for it."
    elif $WANT_BETA; then
        echo "- \`beta\` sees only pages and blocks tagged for it."
    fi
    echo "- \`public\` sees only pages and blocks tagged \`public\`, and blocks whose marker only excludes others, such as \`!beta\`."
    echo
    echo "The other builds carry no marks: their pages hold the content and nothing about who else sees it."
    echo
    cat <<'BD_EXAMPLES_BLOCK_EOF'
## Block markers

```markdown
<!-- audience: internal -->
## Internal-only section

Shown only in the internal build. Markdown inside renders normally.
<!-- /audience -->
```

BD_EXAMPLES_BLOCK_EOF
    if $WANT_DESIGN; then
        echo "In the internal build, a marker on a line of its own marks the block up to its closing marker."
        echo "A marker inside a line of text marks just that text: a tint that names its audiences when you point at it."
        echo
    fi
    cat <<'BD_EXAMPLES_RULES_EOF'
Markers match in any case, and don't nest.
A marker never closed, a closing marker with nothing to close, a marker opened inside another, and a comment that looks like a marker but isn't one, such as `<!-- audiences: internal -->`, each stop the build, since each would let marked text reach every build.

BD_EXAMPLES_RULES_EOF
    cat <<'BD_EXAMPLES_SYNTAX_EOF'
## Inline markers

```markdown
Your API key is <!-- audience: internal -->`sk_live_...` from the vault<!-- /audience --><!-- audience: public -->in your dashboard<!-- /audience -->.
```

## Several audiences

```markdown
<!-- audience: internal, partner -->
Shown to internal and partner.
<!-- /audience -->
```

## Negation

```markdown
<!-- audience: !beta -->
Shown to internal and public. Not to partner, not to beta.
<!-- /audience -->
```

A marker with only negations shows to public, and to internal unless it names `!internal`.
Every other audience must be named to see something.

## Combined

```markdown
<!-- audience: partner, !beta -->
Shown to internal and partner. Beta is excluded; public isn't named.
<!-- /audience -->
```

## Whole pages

```markdown
---
audiences: [partner]
---
```

Use a YAML list; a comma-separated string also works.
Every page names its audiences: a page that names none stops the build, which lists every such page, so a page whose tag was forgotten can't reach public.
BD_EXAMPLES_SYNTAX_EOF
    if $WANT_PARTNER && $WANT_BETA; then
        echo "To share a page with partners and beta testers too, tag it \`audiences: [$(external_list)]\`."
    elif $WANT_PARTNER; then
        echo "To share a page with partners too, tag it \`audiences: [$(external_list)]\`."
    elif $WANT_BETA; then
        echo "To share a page with beta testers too, tag it \`audiences: [$(external_list)]\`."
    fi
    cat <<'BD_EXAMPLES_TAIL_EOF'
A mistake that would leave a tagged page untagged, such as invalid YAML or a misspelt key, stops the build too, and names the page.

## Files that aren't pages

An image, a PDF or any other file that isn't a page goes into a build only if a page in that build links to it.
The internal build gets every file.
Files under `assets/`, and `CNAME`, `robots.txt` and `favicon.ico` at the top of `docs/`, go into every build.

## Linking to restricted pages

A link from a shared page to a page some audiences can't see breaks in their builds, and strict mode then fails the build.
Put such links inside a marker for the audiences that can see the target:

```markdown
<!-- audience: partner -->
Partners: see the [Partner integration guide](../integrations/partners.md).
It covers keys and webhooks.
<!-- /audience -->
```

A link to a heading inside a marker breaks too, in the builds that leave the heading out.
Put such a link inside a marker for the same audiences.

## Markers inside code

Markers are left exactly as written inside a fenced code block that starts at the left margin, and inside inline code in single backticks, which is how this page shows them.
In other code, such as a fence inside a list item or an admonition, or inline code in double backticks, a marker is acted on like any other.
BD_EXAMPLES_TAIL_EOF
    if $WANT_LEAK_CHECK; then
        cat <<'BD_EXAMPLES_CHECK_EOF'

## Checking for leaks

```bash
bin/check-leaks.py
```

It builds every audience and fails if a raw marker survived, an excluded page was built, any page or the search index points at an excluded page, a build holds a file none of its pages uses, or a link in a build leads to a page, file or anchor that isn't in it.
BD_EXAMPLES_CHECK_EOF
    fi
}

scaffold_plugin() {
    write_owned plugins/mkdocs-audience/pyproject.toml <<'BD_PYPROJECT_EOF'
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mkdocs-audience"
version = "0.4.1"
description = "MkDocs plugin for filtering content by audience at build time"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "jake-47" }]
keywords = ["mkdocs", "mkdocs-plugin", "documentation", "audience", "conditional-content"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Topic :: Documentation",
    "Topic :: Text Processing :: Markup :: Markdown",
]
dependencies = [
    "mkdocs>=1.5,<2",
    "PyYAML>=6.0",
]

[project.optional-dependencies]
dev = ["pytest>=7.0"]

[project.urls]
Homepage = "https://github.com/jake-47/mkdocs-audience"
Repository = "https://github.com/jake-47/mkdocs-audience"
Issues = "https://github.com/jake-47/mkdocs-audience/issues"

[project.entry-points."mkdocs.plugins"]
audience = "mkdocs_audience.plugin:AudiencePlugin"

[tool.hatch.build.targets.wheel]
packages = ["mkdocs_audience"]
BD_PYPROJECT_EOF
    write_owned plugins/mkdocs-audience/README.md <<'BD_README_EOF'
# mkdocs-audience

MkDocs plugin for audience-tagged docs. Mark sections of a page with `<!-- audience: internal -->...<!-- /audience -->`, and tag every page with `audiences: [public]`, `[partner]` and so on in frontmatter, and each build keeps or strips content for the audience you build for. Pages excluded from a build are also removed from your `nav:`, so their titles and links don't reach that build, and files that aren't pages reach a build only if something in it uses them. In the internal build, every marked block and span is wrapped in HTML that says which other audiences get it, so a theme can show reviewers who sees what; every other build gets the content alone.

About 790 lines of Python. Depends only on MkDocs 1.x and PyYAML.

Not a security boundary. The source still contains every audience's content; access control is your repository's job. Verify each build's output before publishing it.

## Platform support

- **MkDocs 1.x**: supported. The plugin requires `mkdocs>=1.5,<2`.
- **ProperDocs** (a drop-in fork of MkDocs 1.x): works unchanged. Build with `properdocs build`.
- **MkDocs 2.0**: not supported. It has no plugin system.
- **Zensical**: not supported. Zensical silently ignores plugins it doesn't list, so a Zensical build publishes every audience's content with no error. Don't build an audience-tagged project with Zensical.

## Install

The package isn't on PyPI. Install it from a local copy or from its Git repository:

```bash
pip install ./plugins/mkdocs-audience                                  # local copy
pip install git+https://github.com/jake-47/mkdocs-audience             # from Git
```

`bootstrap-docs` vendors this package into every project it creates, under `plugins/mkdocs-audience/`, and installs it from there.

In `mkdocs.yml`:

```yaml
plugins:
  - search
  - audience:
      audiences: [internal, partner, beta, public]

markdown_extensions:
  - md_in_html        # needed for markdown inside block markers
```

Pick the audience per build with an environment variable:

```bash
MKDOCS_AUDIENCE=internal mkdocs serve
MKDOCS_AUDIENCE=public mkdocs build --site-dir dist/public
```

The active audience is resolved in this order: `MKDOCS_AUDIENCE`, then the plugin's `audience:` setting, then the first name in `audiences:`. Setting `audience: public` makes a build without the variable, such as a plain `mkdocs gh-deploy`, the public one.

## Marker syntax

Block marker:

```markdown
<!-- audience: internal -->
## On-call runbook

Escalation: PagerDuty schedule `prod-oncall`.
<!-- /audience -->
```

Inline marker:

```markdown
Your API key is <!-- audience: internal -->`sk_live_...` from the vault<!-- /audience --><!-- audience: public -->in your dashboard<!-- /audience -->.
```

Whole-page targeting (use a YAML list; a comma-separated string also works):

```markdown
---
audiences: [partner]
---
```

The plugin finds page metadata where MkDocs does: a YAML block closed by `---` or `...`, or MultiMarkdown-style `audiences: partner` lines at the top of the page. It matches the `audiences` key in any case. A mistake that would make a tagged page count as untagged stops the build instead of publishing the page: frontmatter that names audiences but isn't valid YAML; an `audiences:` value that is empty, isn't a list or string, or names `!audience`; a key that is almost `audiences`, such as `audience` or `audeinces`; and, on a page MkDocs reads no audiences from, a line in its first 60 lines, outside code, that looks like a tag, such as one in a block below the first line, in a block never closed, or missing its colon.

A spec can list several names and negate with `!`: `<!-- audience: partner, !beta -->`.

Markers match in any case and with any spacing around the word and the colon. Markers inside fenced code that starts at the left margin, and inside inline code in single backticks, are left as written. Anything else that would let marked text reach the wrong build stops the build and names the page: an opening marker never closed, a closing marker with no opening one, a marker opened inside another (markers don't nest), and a comment that looks like a marker but isn't one, such as `<!-- audiences: internal -->`.

## Matching rule

Inline markers (audience `A`, included names `I`, excluded names `E`):

1. Empty spec: hidden in every build (fail closed).
2. `A` in `E`: hidden (exclusions always win).
3. `A` is `internal`: shown (internal sees everything not explicitly excluded).
4. `I` non-empty: shown only if `A` is in `I`.
5. `I` empty (negation only, like `!beta`): shown only to `public`.

Whole pages (audience `A`, frontmatter list `P`):

1. `A` is `internal`: the page is built.
2. `P` names audiences: built only if `A` is in `P`.
3. No `audiences:` frontmatter: the `untagged` setting decides. With `error`, the default, the build stops and lists every such page, so a page whose tag you forgot can't reach the public build. With `internal`, the page is built only for internal. With `public`, it's built for internal and public, as in versions before 0.4.0.

Restrictive by design: a page reaches partner, beta or public only when it says so. To share a page, tag it, for example `audiences: [public, partner, beta]`. Links from a shared page to a restricted page belong inside a marker for that audience; otherwise the link breaks in builds where the target is excluded, and strict mode fails the build.

## Files that aren't pages

Images, PDFs and other files under `docs_dir` are filtered by use. In every build but internal, such a file goes in only if a page in that build links to it (an `href`, `src`, `srcset`, `poster` or `data` attribute in the page as rendered, after markers are applied), or it's one of the files every build gets:

- anything under `assets/`
- `CNAME`, `robots.txt` and `favicon.ico` at the top of `docs_dir`
- a hidden file or folder that `exclude_docs` lets in, such as `.well-known/`
- the files `mkdocs.yml` names in `extra_css` and `extra_javascript`, and as the theme's `logo` and `favicon`
- any file a stylesheet that the build keeps refers to with `url()` or `@import`

So an image that only an internal page uses never reaches the public build. The internal build gets every file. Each build logs, at INFO level, the files it left out. Put files your templates or scripts use, and nothing a page links to, under `assets/`.

A `nav:` entry doesn't count as a link, because every build shares the nav. When one points at a file a build leaves out, that build logs a warning, which fails it under `strict: true`, since the entry would be a broken link there.

## Styling

Only the internal build wraps what it keeps:

```html
<div class="audience-partner audience-block" data-audience="partner,beta"
     data-reach="partner beta" data-label="Partner and beta" markdown="1">...</div>
<span class="audience-internal audience-inline" data-audience="internal"
      data-reach="" data-label="Internal only" title="Internal only">...</span>
```

- A marker that starts its line wraps a block (`div`) when it stands alone on that line, when its content runs over several lines, or when it marks a whole line such as a heading, a list item, a quote or a table row; anywhere else it wraps a span. A block's tags take the marker's indentation and sit between blank lines, so a block inside an admonition or a list item renders there.
- The first class names the first non-negated audience in the marker.
- `data-reach` lists the other declared audiences that also get the content, space-separated, so CSS can match one with `[data-reach~="public"]`.
- `data-label` says the same in words.

Every other build gets the content with no wrapper, so its HTML names no audience. The plugin ships no CSS.

## Configuration

```yaml
plugins:
  - audience:
      audiences: [internal, public]   # names you use in markers and frontmatter
      audience: ""                    # optional fixed audience; MKDOCS_AUDIENCE overrides it
      filename_convention: false      # true: _NAME-page.md acts like audiences: [NAME]
      untagged: error                 # error, internal or public; see "Matching rule"
```

The plugin sets `config.extra.audience` (the active name) and `config.extra.audience_<name>` (true for the active one) for use in theme overrides.

## Errors and warnings

- An undeclared or misspelt `MKDOCS_AUDIENCE` stops the build with a one-line message listing the declared names.
- A frontmatter mistake that would make a tagged page count as untagged stops the build and names the page; see above.
- With `untagged: error`, the default, pages that name no audiences stop the build, which lists them all.
- A marker that can't be paired, a nested marker, or a comment that looks like a marker but isn't one stops the build and names the page.
- With `filename_convention: true`, a file name that looks like the convention but doesn't follow it, such as `_internal_notes.md`, stops the build.
- A marker or a page's `audiences:` naming an undeclared audience logs a warning, which fails the build under `strict: true`.
- A `nav:` entry pointing at a file that a build leaves out logs a warning, which fails the build under `strict: true`.

## Tests

```bash
pip install -e ".[dev]"
pytest tests/ -v
# expected: 156 passed
```

## Licence

MIT.
BD_README_EOF
    write_owned plugins/mkdocs-audience/LICENSE <<'BD_LICENSE_EOF'
MIT License

Copyright (c) 2026 jake-47

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
BD_LICENSE_EOF
    write_owned plugins/mkdocs-audience/.gitignore <<'BD_GITIGNORE_EOF'
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
dist/
build/
.DS_Store
BD_GITIGNORE_EOF
    write_owned plugins/mkdocs-audience/mkdocs_audience/__init__.py <<'BD_INIT_EOF'
"""mkdocs-audience: filter MkDocs content by audience at build time."""
from mkdocs_audience.plugin import AudiencePlugin

__all__ = ["AudiencePlugin"]
__version__ = "0.4.1"
BD_INIT_EOF
    write_owned plugins/mkdocs-audience/mkdocs_audience/plugin.py <<'BD_PLUGIN_EOF'
"""MkDocs plugin: filter content by audience at build time.

Mark sections of a page with `<!-- audience: NAME -->...<!-- /audience -->` and
the build strips or keeps them based on the active audience. Whole-page targeting
via frontmatter (`audiences: [name]`) is also supported. Pages excluded from a
build are also removed from the `nav:` in mkdocs.yml, so their titles and links
never reach that build.

Active audience resolution order:
  1. Environment variable MKDOCS_AUDIENCE (per-invocation override)
  2. Plugin config key `audience`
  3. First entry in plugin config `audiences` list

Matching rule for inline markers (audience A, spec parsed into includes I, excludes E):
  1. Empty spec                              -> hidden (fail closed)
  2. A in E                                  -> hidden (excludes always win)
  3. A == 'internal'                         -> shown (privileged superset)
  4. I non-empty                             -> shown if A in I, else hidden
  5. I empty (negation-only spec)            -> shown only to public

Whole-page filtering (audience A, page frontmatter audiences P):
  1. A == 'internal'                         -> page renders (privileged)
  2. P is a list                             -> page renders only if A in P
  3. P is None (no audiences declared)       -> the `untagged` setting decides:
       error (the default)  the build stops and lists every such page
       internal             the page renders only for internal
       public               the page renders for internal and public

The page's metadata is found where MkDocs finds it: a YAML block between `---`
and `---` (or `...`), or MultiMarkdown-style `key: value` lines at the top. Any
mistake that would otherwise make a tagged page count as untagged, and so reach
the public build, stops the build instead: frontmatter that names audiences but
isn't valid YAML, an `audiences:` value that is empty, not a list or string, or
negated, a key that is almost `audiences`, and a line near the top of an
untagged page that looks like a tag MkDocs doesn't read.

Markers match in any case. One that can't be paired, one opened inside
another, and a comment that looks like a marker but isn't one stop the build
too: each would let marked text reach every build.

Files that aren't pages (images, PDFs, downloads) are filtered by use. In
every build but internal, a file from docs_dir goes in only if a page in
that build links to it, or it is one of these, which every build gets:
anything under assets/; CNAME, robots.txt and favicon.ico at the top of
docs_dir; a hidden file that exclude_docs lets in; the stylesheets, scripts,
logo and favicon mkdocs.yml names; and any file a stylesheet that the build
keeps refers to. So an image only a restricted page uses never reaches a
build that can't see the page. The internal build gets every file. The nav
is the same in every build, so it doesn't count as a link: a nav entry that
points at a file a build leaves out logs a warning, since in that build it
would be a broken link.

Only the internal build marks what it keeps:
  <div class="audience-{primary} audience-block" data-audience="..."
       data-reach="partner beta" data-label="Partner and beta" markdown="1">...</div>
  <span class="audience-{primary} audience-inline" data-audience="..."
        data-reach="..." data-label="..." title="...">...</span>
where {primary} is the first non-negated audience in the spec, data-reach lists
the other declared audiences that also get the content, and data-label says the
same in words ("Internal only" when there are none). Every other build gets the
content alone, so its HTML names no audience.

This plugin needs MkDocs 1.x (or a 1.x-compatible fork such as ProperDocs).
MkDocs 2.0 has no plugin system, and Zensical silently ignores plugins it does
not list, so under either one nothing is filtered. Verify every build's output.
"""

from __future__ import annotations

import os
import posixpath
import re
from html import escape
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import unquote, urlsplit

import yaml
from mkdocs.config import config_options
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, get_plugin_logger
from mkdocs.structure.files import Files, InclusionLevel

log = get_plugin_logger(__name__)

# Markers: <!-- audience: SPEC --> ... <!-- /audience -->, in any case, with
# any spacing around the word and the colon.
_MARK_OPEN_RE = re.compile(r"<!--\s*audience\s*:\s*(?P<spec>[^>]*?)\s*-->", re.IGNORECASE)
_MARK_CLOSE_RE = re.compile(r"<!--\s*/\s*audience\s*-->", re.IGNORECASE)
_AUDIENCE_RE = re.compile(
    _MARK_OPEN_RE.pattern + r"(?P<body>.*?)" + _MARK_CLOSE_RE.pattern,
    re.DOTALL | re.IGNORECASE,
)
# Any comment that starts with a word, to find ones that look like markers but aren't.
_COMMENT_WORD_RE = re.compile(r"<!--+\s*/?\s*(?P<word>[A-Za-z]+)")
# A line that looks like it tags the page: "audiences: ...", "- audience: ...",
# "audiences = ...", "audiences [...]", "Audeinces: ...".
_AUDIENCE_LINE_RE = re.compile(r"""^\s*(?:[-*]\s*)?["']?(?P<key>[A-Za-z][\w-]*)["']?\s*(?:[:=]|\[)""")
# Content that has to start a block of its own: a heading, a list item, a quote,
# a table row or a fence.
_BLOCK_START_RE = re.compile(r"^(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~)")

_FENCE_RE = re.compile(
    r"(?ms)"
    r"(?P<fenced>^[ ]{0,3}(?P<fence>```+|~~~+)[^\n]*\n.*?^[ ]{0,3}(?P=fence)[ \t]*$)"
    r"|"
    r"(?P<inline>`[^`\n]*`)",
)

_FILENAME_AUDIENCE_RE = re.compile(r"^_(?P<audience>[a-z]+)-")
_FILENAME_NEAR_RE = re.compile(r"^_[A-Za-z]+[-_]")

# The same patterns MkDocs 1.x uses to find page metadata (mkdocs/utils/meta.py),
# copied so the plugin never reads a page's metadata differently from MkDocs.
_YAML_RE = re.compile(r"^-{3}[ \t]*\n(.*?\n)(?:\.{3}|-{3})[ \t]*\n", re.DOTALL)
_META_RE = re.compile(r"^[ ]{0,3}(?P<key>[A-Za-z0-9_-]+):\s*(?P<value>.*)")
_META_MORE_RE = re.compile(r"^([ ]{4}|\t)(\s*)(?P<value>.*)")

PRIVILEGED = "internal"
ENV_VAR = "MKDOCS_AUDIENCE"
UNTAGGED_CHOICES = ("error", "internal", "public")

# Files every build gets, whether or not a page links to them.
_ALWAYS_KEPT_TOP = {"CNAME", "robots.txt", "favicon.ico"}
# url(...) and @import in a stylesheet.
_CSS_REF_RE = re.compile(
    r"""url\(\s*(['"]?)(?P<url>[^'")]+)\1\s*\)|@import\s+(['"])(?P<imp>[^'"]+)\3"""
)


class _TolerantLoader(yaml.SafeLoader):
    """SafeLoader that reads tags it doesn't know (!ENV, !team, ...) as null."""


_TolerantLoader.add_multi_constructor("", lambda loader, suffix, node: None)


class AudiencePlugin(BasePlugin):
    config_scheme = (
        (
            "audiences",
            config_options.ListOfItems(
                config_options.Type(str),
                default=["internal", "public"],
            ),
        ),
        ("audience", config_options.Type(str, default="")),
        ("filename_convention", config_options.Type(bool, default=False)),
        ("untagged", config_options.Choice(UNTAGGED_CHOICES, default="error")),
    )

    def on_config(self, config):
        declared = list(self.config["audiences"])
        if not declared:
            raise PluginError(
                "mkdocs-audience: the plugin's 'audiences:' list in mkdocs.yml is empty. "
                "Declare at least one audience, for example: audiences: [internal, public]"
            )
        chosen = (
            os.environ.get(ENV_VAR)
            or self.config.get("audience")
            or declared[0]
        )
        if chosen not in declared:
            raise PluginError(
                f"mkdocs-audience: audience {chosen!r} is not declared. "
                f"Declared audiences: {', '.join(declared)}. "
                f"Set {ENV_VAR} to one of them, or add {chosen!r} to the "
                f"plugin's 'audiences:' list in mkdocs.yml."
            )
        self._audience = chosen

        extra = config.setdefault("extra", {})
        extra["audience"] = chosen
        for aud in declared:
            extra[f"audience_{aud}"] = (aud == chosen)
        return config

    def on_files(self, files, config):
        declared = set(self.config["audiences"])
        policy = self.config.get("untagged", "error")
        kept, excluded, untagged = [], set(), []
        for f in files:
            # A page exclude_docs leaves out is never built, so it isn't checked.
            if not f.is_documentation_page() or f.inclusion == InclusionLevel.EXCLUDED:
                kept.append(f)
                continue
            names = _page_audiences(f, config["docs_dir"], self.config["filename_convention"])
            for name in names or []:
                if name not in declared:
                    log.warning(
                        "undeclared audience %r in the frontmatter or file name of %s (declared: %s)",
                        name, f.src_path, sorted(declared),
                    )
            if names is None and policy == "error":
                untagged.append(_norm(f.src_uri))
            elif _visible(self._audience, names, policy):
                kept.append(f)
            else:
                excluded.add(_norm(f.src_uri))
        if untagged:
            raise PluginError(_untagged_message(sorted(untagged)))
        # MkDocs builds the navigation from config["nav"] after this hook, so
        # pruning here keeps excluded pages' titles and links out of the build
        # and avoids "not found in the documentation files" warnings.
        if excluded and config.get("nav"):
            config["nav"] = _prune_nav(config["nav"], excluded)
        return Files(kept)

    def on_page_markdown(self, markdown, page, config, files):
        self._warn_undeclared(markdown, page)
        return self._apply_audience(markdown, page.file.src_path)

    def on_env(self, env, config, files):
        """Leave out of this build each file from docs_dir that isn't a page and
        that nothing in the build uses (see the module docstring). Runs after
        every page is rendered and before MkDocs copies the files."""
        if self._audience == PRIVILEGED:
            return env
        docs_dir = os.path.normpath(str(config["docs_dir"]))
        site_url = str(config.get("site_url") or "")
        static = {
            f.dest_uri: f for f in files
            if not f.is_documentation_page()
            and f.src_dir and os.path.normpath(str(f.src_dir)) == docs_dir
            and f.inclusion.is_included()
        }
        used = set()
        for f in files.documentation_pages(inclusion=lambda level: True):
            page = f.page
            if page is None or page.content is None:
                continue
            base = _url_dir(page.url)
            for ref in _html_refs(page.content):
                target = _resolve_ref(base, ref, site_url)
                if target:
                    used.add(target)
        keep = _config_paths(config)
        # Follow stylesheets the build keeps to the files they use.
        queue = [uri for uri in static if uri.lower().endswith(".css")
                 and (_always_kept(uri) or uri in keep or uri in used)]
        seen = set()
        while queue:
            uri = queue.pop()
            if uri in seen:
                continue
            seen.add(uri)
            try:
                text = Path(static[uri].abs_src_path).read_text(encoding="utf-8", errors="replace")
            except OSError:
                continue
            for ref in _css_refs(text):
                target = _resolve_ref(posixpath.dirname(uri), ref, site_url)
                if target and target not in keep:
                    keep.add(target)
                    if target.lower().endswith(".css") and target in static:
                        queue.append(target)
        dropped = []
        for uri, f in static.items():
            if _always_kept(uri) or uri in keep or uri in used:
                continue
            f.inclusion = InclusionLevel.EXCLUDED
            dropped.append(uri)
        if dropped:
            log.info(
                "left %d file%s out of the %r build, as no page in it links to %s: %s",
                len(dropped), "" if len(dropped) == 1 else "s", self._audience,
                "it" if len(dropped) == 1 else "them", ", ".join(sorted(dropped)),
            )
        # Every build shares the nav, so a nav entry isn't a link that keeps a
        # file; one that points at a file this build leaves out is broken here.
        for target in sorted(set(dropped).intersection(_nav_targets(config.get("nav")))):
            log.warning(
                "the nav links to %s, but no page in the %r build links to it, so the build "
                "leaves it out and the nav entry would be a broken link. Link to it from a page "
                "in this build, move it under assets/, which every build gets, or take it out "
                "of the nav.",
                target, self._audience,
            )
        return env

    def _apply_audience(self, text, src_path="this page"):
        """Replace audience markers; markers inside code are left as written.

        A marker that can't be paired, or a comment that looks like a marker but
        isn't one, stops the build: either would let marked content reach every
        build. Only the internal build marks what it keeps. There, each block or
        span is wrapped with data-reach (the other audiences that also get it)
        and data-label (the same in words), which a theme can draw as a rule and
        a note. Every other build gets the content alone, so its HTML names no
        audience at all.
        """
        in_code = _fence_checker(text)
        others = [a for a in self.config["audiences"] if a != PRIVILEGED]
        pairs = _pair_markers(text, in_code, src_path)
        out, pos = [], 0
        for opening, closing in pairs:
            out.append(text[pos:opening.start()])
            out.append(self._replace(text, opening, closing, others))
            pos = closing.end()
        out.append(text[pos:])
        return "".join(out)

    def _replace(self, text, opening, closing, others):
        spec = _split_spec(opening.group("spec"))
        body = text[opening.end():closing.start()]
        if not _matches(self._audience, spec):
            return ""
        if self._audience != PRIVILEGED:
            return body
        reach = [a for a in others if _matches(a, spec)]
        label = _reach_label(reach)
        primary = _primary_audience(spec) or self._audience
        attrs = (
            f'data-audience="{escape(",".join(spec))}" '
            f'data-reach="{escape(" ".join(reach))}" '
            f'data-label="{escape(label)}"'
        )
        # A marker that starts its line wraps a block when it stands alone on
        # that line, when its content runs over several lines, or when it marks
        # a whole line that is a heading, a list item, a quote or a table row;
        # anywhere else it marks a span. The block's tags take the marker's
        # indentation and sit between blank lines, so Markdown renders a block
        # inside an admonition or a list item as a block, and keeps it there.
        line_start = text.rfind("\n", 0, opening.start()) + 1
        indent = text[line_start:opening.start()]
        line_end = text.find("\n", closing.end())
        rest = text[closing.end():] if line_end < 0 else text[closing.end():line_end]
        alone = re.match(r"[ \t]*\n", body) is not None
        whole_block_line = not rest.strip() and _BLOCK_START_RE.match(body.lstrip()) is not None
        if not indent.strip() and (alone or "\n" in body.strip() or whole_block_line):
            inner = body.rstrip(" \t")
            if not alone:
                inner = "\n" + indent + inner
            if not inner.endswith("\n"):
                inner += "\n"
            return (
                f'\n{indent}<div class="audience-{escape(primary)} audience-block" {attrs} '
                f'markdown="1">\n{inner}\n{indent}</div>\n'
            )
        return (
            f'<span class="audience-{escape(primary)} audience-inline" {attrs} '
            f'title="{escape(label)}">{body}</span>'
        )

    def _warn_undeclared(self, markdown, page):
        # Markers inside code are examples, not markers: skip them, exactly as
        # _apply_audience does, or a page that documents the syntax fails
        # strict builds whenever an audience it mentions isn't declared.
        declared = set(self.config["audiences"])
        in_code = _fence_checker(markdown)
        for m in _MARK_OPEN_RE.finditer(markdown):
            if in_code(m.start()):
                continue
            for token in _split_spec(m.group("spec")):
                name = token.lstrip("!").strip()
                if name and name not in declared:
                    log.warning(
                        "undeclared audience %r in %s (declared: %s)",
                        name, page.file.src_path, sorted(declared),
                    )


def _snippet(text, pos):
    end = text.find("-->", pos)
    end = len(text) if end < 0 else end + 3
    return " ".join(text[pos:min(end, pos + 80)].split())


def _pair_markers(text, in_code, src_path):
    """Pair each opening marker outside code with the next closing one.

    Raises PluginError for a marker that opens inside another, a closing marker
    with no opening one, an opening marker never closed, and a comment that
    looks like a marker but isn't one.
    """
    opens = [m for m in _MARK_OPEN_RE.finditer(text) if not in_code(m.start())]
    closes = [m for m in _MARK_CLOSE_RE.finditer(text) if not in_code(m.start())]
    markers = {m.start() for m in opens + closes}
    for m in _COMMENT_WORD_RE.finditer(text):
        if m.start() in markers or in_code(m.start()):
            continue
        word = m.group("word").lower()
        if word.startswith("audience") or _edit_distance(word, "audience") <= 2:
            raise PluginError(
                f"mkdocs-audience: in {src_path}, {_snippet(text, m.start())!r} looks like an "
                f"audience marker but isn't one, so the text it was meant to mark would reach "
                f"every build. Write markers as <!-- audience: NAME --> and <!-- /audience -->."
            )
    events = sorted([(m.start(), 0, m) for m in opens] + [(m.start(), 1, m) for m in closes],
                    key=lambda e: e[0])
    pairs, pending = [], None
    for _, is_close, m in events:
        if not is_close:
            if pending is not None:
                raise PluginError(
                    f"mkdocs-audience: in {src_path}, the marker {_snippet(text, m.start())!r} "
                    f"opens inside another one ({_snippet(text, pending.start())!r}). Markers "
                    f"can't be nested: close the first one before opening the next."
                )
            pending = m
        else:
            if pending is None:
                raise PluginError(
                    f"mkdocs-audience: in {src_path}, a closing marker <!-- /audience --> has "
                    f"no opening marker before it."
                )
            pairs.append((pending, m))
            pending = None
    if pending is not None:
        raise PluginError(
            f"mkdocs-audience: in {src_path}, the marker {_snippet(text, pending.start())!r} "
            f"is never closed with <!-- /audience -->. Until it is, what follows it would "
            f"reach every build."
        )
    return pairs


def _fence_checker(text):
    """Return a function telling whether a position lies in fenced or inline code."""
    regions = [(m.start(), m.end()) for m in _FENCE_RE.finditer(text)]
    return lambda pos: any(start <= pos < end for start, end in regions)


def _norm(path):
    """Normalize a docs-relative path for comparison (POSIX, no leading ./ or /)."""
    path = str(path).replace(os.sep, "/").strip()
    while path.startswith("./"):
        path = path[2:]
    return path.lstrip("/")


def _untagged_message(paths):
    n = len(paths)
    shown = ", ".join(paths[:10]) + (f", and {n - 10} more" if n > 10 else "")
    return (
        f"mkdocs-audience: {n} page{'s' if n != 1 else ''} name{'' if n != 1 else 's'} no "
        f"audiences, and every page has to say who gets it: {shown}. Add audiences: to "
        f"each one's frontmatter, as in audiences: [public] for the open site or "
        f"audiences: [internal] to keep it internal. To build untagged pages for public "
        f"instead, set untagged: public in the audience plugin's settings in mkdocs.yml."
    )


class _Refs(HTMLParser):
    """Every URL an element in the HTML points at: href, src, srcset, poster, data."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.refs = []

    def handle_starttag(self, tag, attrs):
        for name, value in attrs:
            if not value:
                continue
            if name in ("href", "src", "poster", "data"):
                self.refs.append(value)
            elif name == "srcset":
                self.refs.extend(part.strip().split()[0] for part in value.split(",") if part.strip())


def _html_refs(html):
    parser = _Refs()
    try:
        parser.feed(html)
        parser.close()
    except Exception:  # noqa: BLE001 - a page MkDocs rendered is parseable; be safe
        pass
    return parser.refs


def _css_refs(text):
    return [m.group("url") or m.group("imp") for m in _CSS_REF_RE.finditer(text)]


def _url_dir(url):
    """The folder a page's relative links start from: 'api/overview/' -> 'api/overview'."""
    url = unquote(str(url or ""))
    return url[:-1] if url.endswith("/") else posixpath.dirname(url)


def _resolve_ref(base, ref, site_url=""):
    """The docs-relative path a link or a url() points at, or None if it leaves the site."""
    ref = str(ref).strip()
    if site_url:
        for prefix in (site_url, site_url.rstrip("/")):
            if prefix and ref.startswith(prefix):
                ref = "/" + ref[len(prefix):].lstrip("/")
                break
    if not ref or ref.startswith(("#", "//", "mailto:", "tel:", "javascript:", "data:")):
        return None
    ref = ref.split("#", 1)[0].split("?", 1)[0]
    if "://" in ref:
        return None
    ref = unquote(ref)
    if ref.startswith("/"):
        path = urlsplit(site_url).path if site_url else "/"
        path = "/" + path.strip("/") + "/" if path.strip("/") else "/"
        ref = ref[len(path):] if ref.startswith(path) else ref.lstrip("/")
        resolved = posixpath.normpath(ref or ".")
    else:
        resolved = posixpath.normpath(posixpath.join(base, ref))
    if resolved in (".", "") or resolved.startswith("../") or resolved == "..":
        return None
    return resolved


def _always_kept(path):
    """A file every build gets: assets/, the hosting files at the top, hidden files."""
    parts = path.split("/")
    return parts[0] == "assets" or path in _ALWAYS_KEPT_TOP or any(p.startswith(".") for p in parts)


def _config_paths(config):
    """The files mkdocs.yml names: extra_css, extra_javascript, the logo and favicon."""
    found = set()
    items = list(config.get("extra_css") or []) + list(config.get("extra_javascript") or [])
    theme = config.get("theme")
    for key in ("logo", "favicon"):
        try:
            value = theme[key] if theme is not None else None
        except (KeyError, TypeError):
            value = None
        if value:
            items.append(value)
    for item in items:
        target = _resolve_ref("", str(getattr(item, "path", item)))
        if target:
            found.add(target)
    return found


def _nav_targets(items):
    """The docs-relative path of every nav entry that points into the site."""
    found = []
    for item in items or []:
        value = item
        if isinstance(item, dict) and len(item) == 1:
            (value,) = item.values()
        if isinstance(value, list):
            found.extend(_nav_targets(value))
        elif isinstance(value, str):
            target = _resolve_ref("", value)
            if target:
                found.append(target)
    return found


def _prune_nav(items, excluded):
    """Return nav `items` without entries pointing at excluded pages.

    Handles the three nav item shapes MkDocs accepts: "page.md",
    {"Title": "page.md"} and {"Section": [children]}. Sections left with no
    children are dropped. External links and anything unrecognized are kept.
    """
    out = []
    for item in items:
        if isinstance(item, str):
            if _norm(item) not in excluded:
                out.append(item)
        elif isinstance(item, dict) and len(item) == 1:
            ((title, value),) = item.items()
            if isinstance(value, list):
                children = _prune_nav(value, excluded)
                if children:
                    out.append({title: children})
            elif isinstance(value, str) and _norm(value) in excluded:
                continue
            else:
                out.append(item)
        else:
            out.append(item)
    return out


def _visible(audience, names, untagged="public"):
    """Whole-page rule: internal sees every page; an untagged page goes to public
    only when the untagged setting says public."""
    if audience == PRIVILEGED:
        return True
    if names is None:
        return untagged == "public" and audience == "public"
    return audience in names


def _page_passes(audience, file, docs_dir, use_filename_convention, untagged="public"):
    return _visible(audience, _page_audiences(file, docs_dir, use_filename_convention), untagged)


def _page_audiences(file, docs_dir, use_filename_convention):
    """The audiences a page is tagged for, or None for an untagged page.

    Raises PluginError for a page whose tagging can't be trusted, which would
    otherwise be treated as untagged and so built for public.
    """
    audiences = []
    name = Path(file.src_path).name
    if use_filename_convention:
        m = _FILENAME_AUDIENCE_RE.match(name)
        if m:
            audiences.append(m.group("audience"))
        elif _FILENAME_NEAR_RE.match(name):
            raise PluginError(
                f"mkdocs-audience: the file name of {file.src_path} looks like the _NAME-page.md "
                f"convention but doesn't follow it, so the page would be built for public. "
                f"Write it as _NAME-rest.md, with NAME in lower case and a hyphen after it."
            )
    try:
        text = (Path(docs_dir) / file.src_path).read_text(encoding="utf-8-sig")
    except (OSError, UnicodeDecodeError):
        # MkDocs fails on this page itself; nothing to tag.
        return audiences or None
    meta = _read_meta(text, file.src_path)
    keys = [k for k in meta if str(k).lower() == "audiences"]
    if not keys:
        near = [str(k) for k in meta if _audiences_like(str(k))]
        if near:
            raise PluginError(
                f"mkdocs-audience: the frontmatter key {near[0]!r} in {file.src_path} isn't "
                f"'audiences', so the page counts as untagged and would be built for public. "
                f"Spell the key 'audiences'."
            )
        stray = _stray_audience_line(text)
        if stray:
            raise PluginError(
                f"mkdocs-audience: {file.src_path} has the line {stray!r} near its top, but "
                f"MkDocs reads no audiences from the page, so it would be built for public. "
                f"Tag the page in a frontmatter block that starts on its first line with --- "
                f"and ends with ---; if the line isn't a tag, put it in a code block."
            )
    for key in keys:
        raw = meta[key]
        if isinstance(raw, str):
            names = [s.strip() for s in raw.split(",") if s.strip()]
        elif isinstance(raw, list):
            if any(s is None for s in raw):
                raise PluginError(
                    f"mkdocs-audience: 'audiences:' in {file.src_path} has an empty entry. "
                    f"Frontmatter takes audience names only; YAML reads '!name' as a tag, "
                    f"so negation works in markers but not here."
                )
            names = [str(s).strip() for s in raw if str(s).strip()]
        elif raw is None:
            raise PluginError(
                f"mkdocs-audience: 'audiences:' in {file.src_path} has no value. "
                f"Name the audiences, as in audiences: [partner]. If you wrote '!name', "
                f"YAML read it as a tag: negation works in markers but not here."
            )
        else:
            raise PluginError(
                f"mkdocs-audience: 'audiences:' in {file.src_path} must be a list such as "
                f"[partner] or a comma-separated string, not {raw!r}."
            )
        if not names:
            raise PluginError(
                f"mkdocs-audience: 'audiences:' in {file.src_path} names no audience. "
                f"To keep the page internal-only, write: audiences: [internal]"
            )
        negated = [n for n in names if n.startswith("!")]
        if negated:
            raise PluginError(
                f"mkdocs-audience: 'audiences:' in {file.src_path} names {negated[0]!r}. "
                f"Negation works in markers but not in frontmatter: list the audiences "
                f"that get the page."
            )
        audiences.extend(names)
    return audiences or None


def _edit_distance(a, b):
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def _audiences_like(key):
    """A key that is almost 'audiences', such as 'audience' or 'audeinces'."""
    key = key.lower()
    return key != "audiences" and _edit_distance(key, "audiences") <= 2


def _audience_line(line):
    """A line that looks like it tags a page: 'audiences: ...', '- audience: ...', and so on."""
    m = _AUDIENCE_LINE_RE.match(line)
    return bool(m) and (m.group("key").lower() == "audiences" or _audiences_like(m.group("key")))


def _stray_audience_line(text, lines=60):
    """The first line near the top of an untagged page, outside code, that looks
    like it tags the page; None if there is none. Such a line is a tag MkDocs
    doesn't read: in a block below the first line, never closed, opened with
    '----', missing its colon, or nested under another key."""
    in_code = _fence_checker(text)
    offset = 0
    for n, line in enumerate(text.split("\n")):
        if n >= lines:
            break
        if not in_code(offset) and _audience_line(line):
            return line.strip()
        offset += len(line) + 1
    return None


def _read_meta(text, src_path):
    """The page's metadata as MkDocs reads it; {} when there is none.

    A YAML block that isn't valid YAML and names audiences raises PluginError:
    MkDocs would show it as text and the page would count as untagged. One that
    doesn't name audiences is left to MkDocs, which shows it as text, as it does
    for a page that opens with a horizontal rule.
    """
    m = _YAML_RE.match(text)
    if m:
        try:
            data = yaml.load(m.group(1), Loader=_TolerantLoader)
        except yaml.YAMLError as exc:
            if not any(_audience_line(line) for line in m.group(1).split("\n")):
                return {}
            mark = getattr(exc, "problem_mark", None)
            where = f" (line {mark.line + 2})" if mark is not None else ""
            hint = ""
            if re.search(r"(?mi)^\s*audiences\s*:.*!", m.group(1)):
                hint = " Negation ('!name') works in markers, not in frontmatter."
            raise PluginError(
                f"mkdocs-audience: the frontmatter of {src_path} is not valid YAML{where}: "
                f"{getattr(exc, 'problem', None) or exc}. Fix it; until then the page "
                f"would be treated as untagged and built for public.{hint}"
            ) from None
        return data if isinstance(data, dict) else {}
    return _read_multimarkdown_meta(text)


def _read_multimarkdown_meta(text):
    """MultiMarkdown-style 'key: value' lines at the top of a page, as MkDocs reads them."""
    data = {}
    key = None
    for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
        if line.strip() == "":
            break
        m1 = _META_RE.match(line)
        if m1:
            key = m1.group("key").lower().strip()
            value = m1.group("value").strip()
            data[key] = f"{data[key]} {value}" if key in data else value
            continue
        m2 = _META_MORE_RE.match(line)
        if m2 and key:
            data[key] += " " + m2.group("value").strip()
            continue
        break
    return data


def _reach_label(reach):
    """'Partner and beta' for ['partner', 'beta']; 'Internal only' for []."""
    if not reach:
        return "Internal only"
    text = reach[0] if len(reach) == 1 else ", ".join(reach[:-1]) + " and " + reach[-1]
    return text[:1].upper() + text[1:]


def _split_spec(spec):
    return [s.strip() for s in spec.split(",") if s.strip()]


def _primary_audience(spec):
    for token in spec:
        if not token.startswith("!"):
            return token
    return None


def _matches(audience, spec):
    if not spec:
        return False
    includes = [s for s in spec if not s.startswith("!")]
    excludes = [s[1:] for s in spec if s.startswith("!")]
    if audience in excludes:
        return False
    if audience == PRIVILEGED:
        return True
    if includes:
        return audience in includes
    return audience == "public"
BD_PLUGIN_EOF
    write_owned plugins/mkdocs-audience/tests/test_plugin.py <<'BD_TESTS_EOF'
"""Tests for mkdocs-audience plugin internals."""

from __future__ import annotations

import pytest

from mkdocs_audience.plugin import (
    _AUDIENCE_RE,
    _FENCE_RE,
    _matches,
    _split_spec,
    _primary_audience,
    _page_passes,
    AudiencePlugin,
    PRIVILEGED,
)


class TestSplitSpec:
    def test_simple(self):
        assert _split_spec("internal") == ["internal"]

    def test_comma(self):
        assert _split_spec("internal,partner") == ["internal", "partner"]

    def test_whitespace(self):
        assert _split_spec("internal , partner") == ["internal", "partner"]

    def test_empty(self):
        assert _split_spec("") == []

    def test_negation_preserved(self):
        assert _split_spec("!partner") == ["!partner"]


class TestPrimaryAudience:
    def test_first_include_wins(self):
        assert _primary_audience(["internal", "partner"]) == "internal"

    def test_skip_negations(self):
        assert _primary_audience(["!beta", "partner"]) == "partner"

    def test_negation_only_returns_none(self):
        assert _primary_audience(["!beta"]) is None


class TestMatches:
    def test_empty_spec_fails_closed(self):
        assert not _matches("internal", [])
        assert not _matches("public", [])

    def test_internal_is_privileged(self):
        assert _matches("internal", ["partner"])
        assert _matches("internal", ["beta"])
        assert _matches("internal", ["partner", "beta"])

    def test_internal_can_be_excluded(self):
        assert not _matches("internal", ["!internal"])
        assert not _matches("internal", ["partner", "!internal"])

    def test_simple_include(self):
        assert _matches("partner", ["partner"])
        assert not _matches("public", ["partner"])

    def test_multi_include(self):
        assert _matches("partner", ["partner", "beta"])
        assert _matches("beta", ["partner", "beta"])
        assert not _matches("public", ["partner", "beta"])

    def test_negation_only_public_default(self):
        assert _matches("public", ["!beta"])
        assert _matches("internal", ["!beta"])
        assert not _matches("partner", ["!beta"])
        assert not _matches("beta", ["!beta"])

    def test_combined_includes_and_excludes(self):
        assert _matches("partner", ["partner", "!beta"])
        assert not _matches("beta", ["partner", "!beta"])
        assert not _matches("public", ["partner", "!beta"])
        assert _matches("internal", ["partner", "!beta"])


class TestPagePasses:
    class FakeFile:
        def __init__(self, src_path):
            self.src_path = src_path

    def test_internal_sees_all(self, tmp_path):
        (tmp_path / "untagged.md").write_text("# Untagged\n")
        f = self.FakeFile("untagged.md")
        assert _page_passes("internal", f, str(tmp_path), False)

    def test_internal_sees_partner_tagged(self, tmp_path):
        (tmp_path / "p.md").write_text("---\naudiences: [partner]\n---\n# X\n")
        f = self.FakeFile("p.md")
        assert _page_passes("internal", f, str(tmp_path), False)

    def test_public_sees_untagged(self, tmp_path):
        (tmp_path / "untagged.md").write_text("# X\n")
        f = self.FakeFile("untagged.md")
        assert _page_passes("public", f, str(tmp_path), False)

    def test_public_does_not_see_partner_tagged(self, tmp_path):
        (tmp_path / "p.md").write_text("---\naudiences: [partner]\n---\n# X\n")
        f = self.FakeFile("p.md")
        assert not _page_passes("public", f, str(tmp_path), False)

    def test_partner_does_not_see_untagged(self, tmp_path):
        (tmp_path / "untagged.md").write_text("# X\n")
        f = self.FakeFile("untagged.md")
        assert not _page_passes("partner", f, str(tmp_path), False)

    def test_partner_sees_partner_tagged(self, tmp_path):
        (tmp_path / "p.md").write_text("---\naudiences: [partner]\n---\n# X\n")
        f = self.FakeFile("p.md")
        assert _page_passes("partner", f, str(tmp_path), False)

    def test_partner_does_not_see_internal_tagged(self, tmp_path):
        (tmp_path / "i.md").write_text("---\naudiences: [internal]\n---\n# X\n")
        f = self.FakeFile("i.md")
        assert not _page_passes("partner", f, str(tmp_path), False)


class TestRegex:
    def test_block_match(self):
        text = "<!-- audience: internal -->\nbody\n<!-- /audience -->"
        m = _AUDIENCE_RE.search(text)
        assert m is not None
        assert m.group("spec") == "internal"

    def test_inline_match(self):
        text = "x <!-- audience: a -->y<!-- /audience --> z"
        m = _AUDIENCE_RE.search(text)
        assert m is not None
        assert m.group("body") == "y"

    def test_negation_in_spec(self):
        text = "<!-- audience: !public -->body<!-- /audience -->"
        m = _AUDIENCE_RE.search(text)
        assert m.group("spec") == "!public"

    def test_combined_spec(self):
        text = "<!-- audience: internal, partner, !beta -->b<!-- /audience -->"
        m = _AUDIENCE_RE.search(text)
        assert m.group("spec") == "internal, partner, !beta"


class TestFenceProtection:
    def _make_plugin(self, audience, audiences=None):
        p = AudiencePlugin()
        p._audience = audience
        p.config = {"audiences": audiences or ["internal", "public"]}
        return p

    def test_fenced_block_preserves_marker(self):
        p = self._make_plugin("public")
        md = "```\n<!-- audience: internal -->body<!-- /audience -->\n```"
        out = p._apply_audience(md)
        assert "body" in out
        assert "<!-- audience: internal -->" in out

    def test_outside_fence_processed(self):
        p = self._make_plugin("public")
        md = "<!-- audience: internal -->hidden<!-- /audience -->\n```\nfence\n```"
        out = p._apply_audience(md)
        assert "hidden" not in out
        assert "fence" in out

    def test_inline_code_preserves_marker(self):
        p = self._make_plugin("public")
        md = "outer `<!-- audience: internal -->inline<!-- /audience -->` outer"
        out = p._apply_audience(md)
        assert "inline" in out

    def test_tilde_fence(self):
        p = self._make_plugin("public")
        md = "~~~\n<!-- audience: internal -->fence body<!-- /audience -->\n~~~"
        out = p._apply_audience(md)
        assert "fence body" in out


class TestHTMLWrapping:
    def _make_plugin(self, audience, audiences=None):
        p = AudiencePlugin()
        p._audience = audience
        p.config = {"audiences": audiences or ["internal", "public"]}
        return p

    def test_block_wrapped_in_div(self):
        p = self._make_plugin("internal")
        md = "<!-- audience: internal -->\ncontent\nmore\n<!-- /audience -->"
        out = p._apply_audience(md)
        assert '<div class="audience-internal audience-block"' in out
        assert 'data-audience="internal"' in out
        assert 'markdown="1"' in out
        assert "content" in out and "more" in out

    def test_inline_wrapped_in_span(self):
        p = self._make_plugin("internal")
        md = "x <!-- audience: internal -->fragment<!-- /audience --> y"
        out = p._apply_audience(md)
        assert '<span class="audience-internal audience-inline"' in out
        assert "fragment" in out

    def test_unmatched_removed_entirely(self):
        p = self._make_plugin("public")
        md = "before <!-- audience: internal -->secret<!-- /audience --> after"
        out = p._apply_audience(md)
        assert "secret" not in out
        assert "audience-internal" not in out

    def test_primary_class_uses_first_non_negation(self):
        p = self._make_plugin("internal")
        md = "<!-- audience: !beta, partner -->\ncontent\n<!-- /audience -->"
        out = p._apply_audience(md)
        assert 'audience-partner' in out

    # 0.3.0: only the internal build marks content.

    def test_internal_marks_reach_and_label(self):
        p = self._make_plugin("internal", ["internal", "partner", "beta", "public"])
        md = "<!-- audience: partner, beta -->\ncontent\nmore\n<!-- /audience -->"
        out = p._apply_audience(md)
        assert 'data-reach="partner beta"' in out
        assert 'data-label="Partner and beta"' in out

    def test_internal_only_label(self):
        p = self._make_plugin("internal", ["internal", "partner", "public"])
        out = p._apply_audience("x <!-- audience: internal -->y<!-- /audience --> z")
        assert 'data-reach=""' in out
        assert 'title="Internal only"' in out

    def test_negation_only_reaches_public(self):
        p = self._make_plugin("internal", ["internal", "partner", "beta", "public"])
        out = p._apply_audience("<!-- audience: !beta -->\na\nb\n<!-- /audience -->")
        assert 'data-reach="public"' in out
        assert 'data-label="Public"' in out

    @pytest.mark.parametrize("audience", ["partner", "beta", "public"])
    def test_other_builds_get_content_alone(self, audience):
        p = self._make_plugin(audience, ["internal", "partner", "beta", "public"])
        md = (
            "<!-- audience: partner, beta, public -->\nshared\ntext\n<!-- /audience -->\n"
            "x <!-- audience: partner, beta, public -->inline<!-- /audience --> y"
        )
        out = p._apply_audience(md)
        assert "shared" in out and "inline" in out
        for leak in ("audience-", "data-audience", "data-reach", "data-label", "<div", "<span"):
            assert leak not in out

    def test_attribute_values_escaped(self):
        p = self._make_plugin("internal", ["internal", 'pa"rt'])
        out = p._apply_audience('<!-- audience: pa"rt -->\na\nb\n<!-- /audience -->')
        assert 'pa"rt' not in out
        assert "pa&quot;rt" in out

    def test_marker_alone_on_its_line_wraps_a_block_of_one_line(self):
        p = self._make_plugin("internal")
        out = p._apply_audience("<!-- audience: internal -->\n## Heading\n<!-- /audience -->")
        assert out.startswith('\n<div class="audience-internal audience-block"')
        assert out.endswith('markdown="1">\n\n## Heading\n\n</div>\n')

    def test_marker_followed_by_text_on_its_line_marks_a_span(self):
        p = self._make_plugin("internal")
        out = p._apply_audience("<!-- audience: internal -->Secret.<!-- /audience -->")
        assert out.startswith('<span class="audience-internal audience-inline"')

    def test_content_over_several_lines_after_the_marker_wraps_a_block(self):
        p = self._make_plugin("internal")
        out = p._apply_audience("<!-- audience: internal -->Line one\nLine two<!-- /audience -->")
        assert out.endswith('markdown="1">\n\nLine one\nLine two\n\n</div>\n')

    def test_marker_inside_a_line_of_text_marks_a_span(self):
        p = self._make_plugin("internal")
        out = p._apply_audience("Intro <!-- audience: internal -->\nmore\n<!-- /audience -->")
        assert '<span class="audience-internal audience-inline"' in out
        assert "<div" not in out

    def test_block_takes_the_marker_indentation(self):
        p = self._make_plugin("internal")
        md = (
            "!!! note\n"
            "    Before.\n\n"
            "    <!-- audience: internal -->\n"
            "    First.\n"
            "    Second.\n"
            "    <!-- /audience -->\n"
            "    After.\n"
        )
        out = p._apply_audience(md)
        assert '\n    \n    <div class="audience-internal audience-block"' in out
        assert 'markdown="1">\n\n    First.\n    Second.\n\n    </div>\n\n    After.\n' in out


class TestMarkerChecks:
    def _apply(self, md, audience="public"):
        p = AudiencePlugin()
        p._audience = audience
        p.config = {"audiences": ["internal", "partner", "public"]}
        return p._apply_audience(md, "p.md")

    @pytest.mark.parametrize("md", [
        "<!-- audiences: internal -->secret<!-- /audiences -->",
        "<!--- audience: internal --->secret<!--- /audience --->",
        "<!-- audience internal -->secret<!-- /audience -->",
        "<!-- Audiance: internal -->secret<!-- /Audiance -->",
    ])
    def test_comment_that_looks_like_a_marker_stops_the_build(self, md):
        with pytest.raises(PluginError, match="looks like an audience marker"):
            self._apply(md)

    def test_case_and_spacing_are_accepted(self):
        out = self._apply("a <!-- Audience : internal -->secret<!-- / AUDIENCE --> b")
        assert out == "a  b"

    def test_unclosed_marker_stops_the_build(self):
        with pytest.raises(PluginError, match="never closed"):
            self._apply("<!-- audience: internal -->\nsecret\n")

    def test_closing_marker_without_opening_stops_the_build(self):
        with pytest.raises(PluginError, match="no opening marker"):
            self._apply("text\n<!-- /audience -->\n")

    def test_nested_markers_stop_the_build(self):
        md = "<!-- audience: internal -->\na\n<!-- audience: partner -->\nb\n<!-- /audience -->\nc\n<!-- /audience -->\n"
        with pytest.raises(PluginError, match="nested"):
            self._apply(md)

    def test_closing_marker_inside_code_doesnt_end_the_block(self):
        md = (
            "<!-- audience: internal -->\n"
            "Example:\n\n"
            "```markdown\n<!-- /audience -->\n```\n\n"
            "secret follow-up\n"
            "<!-- /audience -->\n\n"
            "```bash\necho hi\n```\n"
        )
        out = self._apply(md)
        assert "secret follow-up" not in out
        assert "echo hi" in out

    def test_marker_in_code_is_not_checked(self):
        md = "```\n<!-- audiences: internal -->x\n```\n\n`<!-- audience: internal -->`\n"
        assert self._apply(md) == md

    def test_whole_line_heading_marker_wraps_a_block(self):
        out = self._apply("<!-- audience: partner -->## Heading<!-- /audience -->\n", "internal")
        assert '<div class="audience-partner audience-block"' in out
        assert "\n## Heading\n" in out

    def test_whole_line_sentence_marker_marks_a_span(self):
        out = self._apply("First.\n<!-- audience: partner -->Second.<!-- /audience -->\nThird.\n", "internal")
        assert '<span class="audience-partner audience-inline"' in out


class TestRendering:
    """The internal build's wrappers must nest correctly once Markdown renders them."""

    def _render(self, md):
        import markdown

        p = AudiencePlugin()
        p._audience = "internal"
        p.config = {"audiences": ["internal", "partner", "public"]}
        return markdown.markdown(p._apply_audience(md), extensions=["md_in_html", "admonition"])

    def test_heading_alone_in_a_block(self):
        html = self._render("<!-- audience: internal -->\n## Heading\n<!-- /audience -->\n\nAfter.\n")
        assert '<h2>Heading</h2>' in html
        assert html.index("audience-block") < html.index("<h2>") < html.index("</div>")
        assert "<span" not in html

    def test_block_inside_an_admonition_stays_inside_it(self):
        html = self._render(
            "!!! note\n"
            "    Before.\n\n"
            "    <!-- audience: partner -->\n"
            "    First.\n"
            "    Second.\n"
            "    <!-- /audience -->\n\n"
            "    Still in the note.\n\n"
            "Outside.\n"
        )
        note_end = html.index("</div>", html.index("Still in the note."))
        assert html.index('class="admonition note"') < html.index("audience-block") < note_end
        assert "<pre>" not in html and "<code>" not in html
        assert "<p><div" not in html
        assert '<p>First.\nSecond.</p>' in html
        assert html.index("Outside.") > note_end

    def test_block_inside_a_list_item_stays_inside_it(self):
        html = self._render(
            "- item one\n\n"
            "    <!-- audience: partner -->\n"
            "    Nested **bold**.\n"
            "    <!-- /audience -->\n\n"
            "- item two\n"
        )
        first_item_end = html.index("</li>")
        assert html.index("audience-block") < first_item_end
        assert "<p>Nested <strong>bold</strong>.</p>" in html
        assert "<p><div" not in html


class TestFullPipeline:
    def _make_plugin(self, audience, audiences=None):
        p = AudiencePlugin()
        p._audience = audience
        p.config = {"audiences": audiences or ["internal", "public"]}
        return p

    def test_marker_in_fence_survives(self):
        p = self._make_plugin("internal")
        md = (
            "before\n"
            "```\n"
            "<!-- audience: internal -->survives<!-- /audience -->\n"
            "```\n"
            "<!-- audience: internal -->\nblock\n<!-- /audience -->\n"
        )

        class _DummyPage:
            class file:
                src_path = "test.md"

        out = p.on_page_markdown(md, _DummyPage(), {}, [])
        assert "<!-- audience: internal -->survives<!-- /audience -->" in out
        assert "audience-internal" in out
        assert "block" in out

    def test_fence_inside_marker_processes_correctly(self):
        p = self._make_plugin("public")
        md = (
            "before\n\n"
            "<!-- audience: internal -->\n"
            "## Internal section\n\n"
            "```bash\n"
            "echo 'inside fence inside marker'\n"
            "```\n"
            "<!-- /audience -->\n\n"
            "after\n"
        )

        class _DummyPage:
            class file:
                src_path = "test.md"

        out = p.on_page_markdown(md, _DummyPage(), {}, [])
        assert "Internal section" not in out
        assert "echo 'inside fence inside marker'" not in out
        assert "<!-- audience:" not in out
        assert "before" in out
        assert "after" in out

    def test_fence_inside_marker_preserved_for_matching_audience(self):
        p = self._make_plugin("internal")
        md = (
            "before\n\n"
            "<!-- audience: internal -->\n"
            "## Internal section\n\n"
            "```bash\n"
            "echo hi\n"
            "```\n"
            "<!-- /audience -->\n\n"
            "after\n"
        )

        class _DummyPage:
            class file:
                src_path = "test.md"

        out = p.on_page_markdown(md, _DummyPage(), {}, [])
        assert "Internal section" in out
        assert "echo hi" in out
        assert 'audience-internal' in out
        assert 'audience-block' in out


# ---------------------------------------------------------------------------
# 0.2.0: audience resolution, clean errors, nav pruning
# ---------------------------------------------------------------------------

from mkdocs.exceptions import PluginError
from mkdocs.structure.files import File, Files

from mkdocs_audience.plugin import _prune_nav, ENV_VAR


def _configured(audiences=("internal", "partner", "beta", "public"), audience="",
                filename_convention=False, untagged="error"):
    p = AudiencePlugin()
    p.config = {
        "audiences": list(audiences),
        "audience": audience,
        "filename_convention": filename_convention,
        "untagged": untagged,
    }
    return p


class TestAudienceResolution:
    def test_env_overrides_config_pin(self, monkeypatch):
        # Regression: a config pin used to beat the env var, so every
        # per-audience build silently became the pinned (internal) build.
        monkeypatch.setenv(ENV_VAR, "public")
        p = _configured(audience="internal")
        cfg = p.on_config({})
        assert p._audience == "public"
        assert cfg["extra"]["audience"] == "public"

    def test_config_pin_used_without_env(self, monkeypatch):
        monkeypatch.delenv(ENV_VAR, raising=False)
        p = _configured(audience="partner")
        p.on_config({})
        assert p._audience == "partner"

    def test_default_is_first_declared(self, monkeypatch):
        monkeypatch.delenv(ENV_VAR, raising=False)
        p = _configured()
        p.on_config({})
        assert p._audience == "internal"

    def test_extra_flags_set(self, monkeypatch):
        monkeypatch.setenv(ENV_VAR, "beta")
        cfg = _configured().on_config({})
        assert cfg["extra"]["audience_beta"] is True
        assert cfg["extra"]["audience_internal"] is False

    def test_unknown_audience_raises_plugin_error(self, monkeypatch):
        monkeypatch.setenv(ENV_VAR, "pubic")
        with pytest.raises(PluginError, match="pubic"):
            _configured().on_config({})

    def test_empty_audience_list_raises_plugin_error(self, monkeypatch):
        monkeypatch.delenv(ENV_VAR, raising=False)
        with pytest.raises(PluginError, match="empty"):
            _configured(audiences=()).on_config({})


class TestPruneNav:
    EXCLUDED = {"integrations/partners.md", "operations/runbook.md"}

    def test_bare_string_entries(self):
        nav = ["index.md", "integrations/partners.md"]
        assert _prune_nav(nav, self.EXCLUDED) == ["index.md"]

    def test_titled_entries(self):
        nav = [{"Home": "index.md"}, {"Partners": "integrations/partners.md"}]
        assert _prune_nav(nav, self.EXCLUDED) == [{"Home": "index.md"}]

    def test_section_left_empty_is_dropped(self):
        nav = [{"Home": "index.md"},
               {"Operations": [{"Runbook": "operations/runbook.md"}]}]
        assert _prune_nav(nav, self.EXCLUDED) == [{"Home": "index.md"}]

    def test_section_keeps_remaining_children(self):
        nav = [{"Guides": [{"A": "a.md"}, {"Partners": "integrations/partners.md"}]}]
        assert _prune_nav(nav, self.EXCLUDED) == [{"Guides": [{"A": "a.md"}]}]

    def test_nested_sections(self):
        nav = [{"Outer": [{"Inner": [{"Runbook": "operations/runbook.md"}]}, "x.md"]}]
        assert _prune_nav(nav, self.EXCLUDED) == [{"Outer": ["x.md"]}]

    def test_external_links_kept(self):
        nav = [{"GitHub": "https://github.com/example/repo"}]
        assert _prune_nav(nav, self.EXCLUDED) == nav

    def test_leading_dot_slash_normalized(self):
        nav = [{"Partners": "./integrations/partners.md"}]
        assert _prune_nav(nav, self.EXCLUDED) == []


class TestOnFilesPrunesNav:
    def _site(self, tmp_path):
        docs = tmp_path / "docs"
        (docs / "integrations").mkdir(parents=True)
        (docs / "operations").mkdir()
        (docs / "index.md").write_text("---\naudiences: [public, partner]\n---\n# Home\n")
        (docs / "integrations" / "partners.md").write_text("---\naudiences: [partner]\n---\n# P\n")
        (docs / "operations" / "runbook.md").write_text("---\naudiences: [internal]\n---\n# R\n")
        paths = ["index.md", "integrations/partners.md", "operations/runbook.md"]
        files = Files([File(p, str(docs), str(tmp_path / "site"), True) for p in paths])
        nav = [{"Home": "index.md"},
               {"Integrations": [{"Partners": "integrations/partners.md"}]},
               {"Operations": [{"Runbook": "operations/runbook.md"}]}]
        return docs, files, nav

    def _run(self, tmp_path, audience, monkeypatch):
        docs, files, nav = self._site(tmp_path)
        monkeypatch.setenv(ENV_VAR, audience)
        p = _configured()
        config = {"docs_dir": str(docs), "nav": nav}
        p.on_config(config)
        kept = p.on_files(files, config)
        return sorted(f.src_uri for f in kept), config["nav"]

    def test_public_build(self, tmp_path, monkeypatch):
        kept, nav = self._run(tmp_path, "public", monkeypatch)
        assert kept == ["index.md"]
        assert nav == [{"Home": "index.md"}]

    def test_partner_build(self, tmp_path, monkeypatch):
        kept, nav = self._run(tmp_path, "partner", monkeypatch)
        assert kept == ["index.md", "integrations/partners.md"]
        assert nav == [{"Home": "index.md"},
                       {"Integrations": [{"Partners": "integrations/partners.md"}]}]

    def test_internal_build_keeps_everything(self, tmp_path, monkeypatch):
        kept, nav = self._run(tmp_path, "internal", monkeypatch)
        assert len(kept) == 3
        assert len(nav) == 3

    def test_no_nav_configured_is_fine(self, tmp_path, monkeypatch):
        docs, files, _ = self._site(tmp_path)
        monkeypatch.setenv(ENV_VAR, "public")
        p = _configured()
        config = {"docs_dir": str(docs), "nav": None}
        p.on_config(config)
        kept = p.on_files(files, config)
        assert [f.src_uri for f in kept] == ["index.md"]
        assert config["nav"] is None


class TestUndeclaredWarnings:
    """Markers inside code are examples; they must not trigger warnings."""

    class _Page:
        class file:
            src_path = "reference.md"

    def _warnings(self, markdown, caplog, audiences=("internal", "public")):
        import logging
        p = _configured(audiences=audiences)
        p._audience = "internal"
        with caplog.at_level(logging.WARNING):
            p.on_page_markdown(markdown, self._Page(), {}, [])
        return [r.getMessage() for r in caplog.records if "undeclared" in r.getMessage()]

    def test_marker_in_fence_does_not_warn(self, caplog):
        md = "```markdown\n<!-- audience: partner -->\nx\n<!-- /audience -->\n```\n"
        assert self._warnings(md, caplog) == []

    def test_marker_in_inline_code_does_not_warn(self, caplog):
        md = "Use `<!-- audience: beta -->x<!-- /audience -->` like this.\n"
        assert self._warnings(md, caplog) == []

    def test_real_marker_still_warns(self, caplog):
        md = "<!-- audience: partner -->\nx\ny\n<!-- /audience -->\n"
        assert any("partner" in w for w in self._warnings(md, caplog))


# ---------------------------------------------------------------------------
# 0.3.0: page metadata is read as MkDocs reads it, and a tagged page can no
# longer fall through to public because of a mistake in its tagging.
# ---------------------------------------------------------------------------

from mkdocs_audience.plugin import _reach_label


class TestReachLabel:
    def test_none(self):
        assert _reach_label([]) == "Internal only"

    def test_one(self):
        assert _reach_label(["public"]) == "Public"

    def test_two(self):
        assert _reach_label(["partner", "beta"]) == "Partner and beta"

    def test_three(self):
        assert _reach_label(["partner", "beta", "public"]) == "Partner, beta and public"


class TestPageMetadata:
    class FakeFile:
        def __init__(self, src_path):
            self.src_path = src_path

    def _passes(self, tmp_path, text, audience):
        (tmp_path / "p.md").write_text(text, encoding="utf-8")
        return _page_passes(audience, self.FakeFile("p.md"), str(tmp_path), False)

    def test_invalid_yaml_stops_the_build(self, tmp_path):
        with pytest.raises(PluginError, match="p.md.*not valid YAML"):
            self._passes(tmp_path, "---\naudiences: [partner\n---\n# X\n", "public")

    def test_dots_close_the_block(self, tmp_path):
        text = "---\naudiences: [partner]\n...\n# X\n"
        assert not self._passes(tmp_path, text, "public")
        assert self._passes(tmp_path, text, "partner")

    def test_multimarkdown_metadata(self, tmp_path):
        text = "Audiences: partner, beta\nTitle: X\n\n# X\n"
        assert not self._passes(tmp_path, text, "public")
        assert self._passes(tmp_path, text, "beta")

    def test_key_case_ignored(self, tmp_path):
        assert not self._passes(tmp_path, "---\nAudiences: [partner]\n---\n# X\n", "public")

    def test_unknown_tags_tolerated(self, tmp_path):
        text = "---\nowner: !team docs\naudiences: [partner]\n---\n# X\n"
        assert not self._passes(tmp_path, text, "public")
        assert self._passes(tmp_path, text, "partner")

    def test_byte_order_mark(self, tmp_path):
        text = "\ufeff---\naudiences: [partner]\n---\n# X\n"
        assert not self._passes(tmp_path, text, "public")

    @pytest.mark.parametrize("value", ["[]", "''", "", "5", "{a: b}"])
    def test_empty_or_wrong_value_stops_the_build(self, tmp_path, value):
        with pytest.raises(PluginError, match="audiences"):
            self._passes(tmp_path, f"---\naudiences: {value}\n---\n# X\n", "public")

    @pytest.mark.parametrize("value", ["[!beta]", "!beta", "\n  - !beta"])
    def test_negation_in_frontmatter_stops_the_build(self, tmp_path, value):
        with pytest.raises(PluginError, match="(?i)negation"):
            self._passes(tmp_path, f"---\naudiences: {value}\n---\n# X\n", "public")

    @pytest.mark.parametrize("key", ["audience", "audeinces", "Audiances", "audiencs"])
    def test_misspelt_key_stops_the_build(self, tmp_path, key):
        with pytest.raises(PluginError, match=f"'{key}'.*built for public"):
            self._passes(tmp_path, f"---\n{key}: [partner]\n---\n# X\n", "public")

    def test_misspelt_multimarkdown_key_stops_the_build(self, tmp_path):
        with pytest.raises(PluginError, match="'audeinces'"):
            self._passes(tmp_path, "Audeinces: partner\n\n# X\n", "public")

    def test_unrelated_keys_are_fine(self, tmp_path):
        assert self._passes(tmp_path, "---\naudio: x\nauthor: y\naudit: z\n---\n# X\n", "public")

    @pytest.mark.parametrize("text", [
        "\n---\naudiences: [partner]\n---\n# X\n",
        "<!-- partner only -->\n---\naudiences: [partner]\n---\n# X\n",
        "---\naudiences: [partner]\n# X\n\nNo closing line.\n",
        "\n\n---\naudeinces: [partner]\n---\n# X\n",
    ])
    def test_frontmatter_mkdocs_cannot_read_stops_the_build(self, tmp_path, text):
        with pytest.raises(PluginError, match="reads no audiences"):
            self._passes(tmp_path, text, "public")

    @pytest.mark.parametrize("text", [
        "---\naudiences [partner]\n---\n# X\n",
        'audiences = ["partner"]\n\n# X\n',
        "---\n- audiences: [internal]\n---\n# X\n",
        "----\naudiences: [partner]\n----\n# X\n",
        "---\ntitle: X\n---\naudiences: [internal]\n\n# X\n",
        "---\nmeta:\n  audiences: [partner]\n---\n# X\n",
        "\naudiences: partner\n\n# X\n",
        "audiences : partner\n\n# X\n",
        "# X\n\n---\naudiences: [partner]\n---\n",
        "---\ntitle: X\n---\n\n---\naudiences: [partner]\n---\n# X\n",
        "---\naudiences: [partner]\n---",
    ])
    def test_tags_mkdocs_reads_no_audiences_from_stop_the_build(self, tmp_path, text):
        with pytest.raises(PluginError, match="reads no audiences|isn't 'audiences'"):
            self._passes(tmp_path, text, "public")

    def test_invalid_yaml_without_audiences_is_left_to_mkdocs(self, tmp_path):
        text = "---\nNote: this page: opens with a rule.\n---\n\n# X\n"
        assert self._passes(tmp_path, text, "public")

    def test_negation_in_a_string_stops_the_build(self, tmp_path):
        with pytest.raises(PluginError, match="(?i)negation"):
            self._passes(tmp_path, "---\naudiences: partner, !beta\n---\n# X\n", "public")

    @pytest.mark.parametrize("name", ["_internal_notes.md", "_Internal-notes.md"])
    def test_filename_convention_near_miss_stops_the_build(self, tmp_path, name):
        (tmp_path / name).write_text("# X\n", encoding="utf-8")
        with pytest.raises(PluginError, match="convention"):
            _page_passes("public", self.FakeFile(name), str(tmp_path), True)

    def test_filename_convention_match(self, tmp_path):
        (tmp_path / "_internal-notes.md").write_text("# X\n", encoding="utf-8")
        assert not _page_passes("public", self.FakeFile("_internal-notes.md"), str(tmp_path), True)
        assert _page_passes("internal", self.FakeFile("_internal-notes.md"), str(tmp_path), True)

    @pytest.mark.parametrize("text", [
        "---\n\n# X\n\nA page that opens with a rule.\n",
        "# X\n\n```\n---\naudiences: [partner]\n---\n```\n",
        "\n---\ntitle: Not about audiences\n---\n# X\n",
    ])
    def test_rules_and_examples_are_not_frontmatter(self, tmp_path, text):
        assert self._passes(tmp_path, text, "public")

    def test_not_frontmatter_when_block_is_not_a_mapping(self, tmp_path):
        # A page opening with rules around plain text: MkDocs reads no metadata.
        assert self._passes(tmp_path, "---\nJust text\n---\n# X\n", "public")


class TestFrontmatterUndeclaredWarning:
    def test_undeclared_name_in_frontmatter_warns(self, tmp_path, monkeypatch, caplog):
        import logging
        docs = tmp_path / "docs"
        docs.mkdir()
        (docs / "p.md").write_text("---\naudiences: [partnr]\n---\n# P\n")
        files = Files([File("p.md", str(docs), str(tmp_path / "site"), True)])
        monkeypatch.setenv(ENV_VAR, "public")
        p = _configured()
        config = {"docs_dir": str(docs), "nav": None}
        p.on_config(config)
        with caplog.at_level(logging.WARNING):
            kept = p.on_files(files, config)
        assert [f.src_uri for f in kept] == []
        assert any("partnr" in r.getMessage() for r in caplog.records)


# ---------------------------------------------------------------------------
# 0.4.0: untagged pages, and files that aren't pages
# ---------------------------------------------------------------------------

from mkdocs.structure.files import InclusionLevel

from mkdocs_audience.plugin import _css_refs, _resolve_ref, _untagged_message


class TestUntagged:
    def _site(self, tmp_path):
        docs = tmp_path / "docs"
        docs.mkdir(exist_ok=True)
        (docs / "index.md").write_text("---\naudiences: [public]\n---\n# Home\n")
        (docs / "notes.md").write_text("# Notes\n")
        (docs / "setup.md").write_text("# Setup\n")
        paths = ["index.md", "notes.md", "setup.md"]
        files = Files([File(p, str(docs), str(tmp_path / "site"), True) for p in paths])
        return docs, files

    def _run(self, tmp_path, monkeypatch, audience, untagged):
        docs, files = self._site(tmp_path)
        monkeypatch.setenv(ENV_VAR, audience)
        p = _configured(untagged=untagged)
        config = {"docs_dir": str(docs), "nav": None}
        p.on_config(config)
        return sorted(f.src_uri for f in p.on_files(files, config))

    @pytest.mark.parametrize("audience", ["internal", "partner", "public"])
    def test_error_is_the_default_and_lists_every_untagged_page(self, tmp_path, monkeypatch, audience):
        docs, files = self._site(tmp_path)
        monkeypatch.setenv(ENV_VAR, audience)
        p = AudiencePlugin()
        p.load_config({"audiences": ["internal", "partner", "beta", "public"]})
        assert p.config["untagged"] == "error"
        config = {"docs_dir": str(docs), "nav": None}
        p.on_config(config)
        with pytest.raises(PluginError, match=r"2 pages name no audiences.*notes\.md, setup\.md.*untagged: public"):
            p.on_files(files, config)

    def test_internal_keeps_untagged_pages_to_itself(self, tmp_path, monkeypatch):
        assert self._run(tmp_path, monkeypatch, "internal", "internal") == ["index.md", "notes.md", "setup.md"]
        assert self._run(tmp_path, monkeypatch, "public", "internal") == ["index.md"]
        assert self._run(tmp_path, monkeypatch, "partner", "internal") == []

    def test_public_sends_untagged_pages_to_public(self, tmp_path, monkeypatch):
        assert self._run(tmp_path, monkeypatch, "public", "public") == ["index.md", "notes.md", "setup.md"]
        assert self._run(tmp_path, monkeypatch, "partner", "public") == []

    def test_page_exclude_docs_leaves_out_is_not_checked(self, tmp_path, monkeypatch):
        docs, files = self._site(tmp_path)
        for f in files:
            if f.src_uri != "index.md":
                f.inclusion = InclusionLevel.EXCLUDED
        monkeypatch.setenv(ENV_VAR, "public")
        p = _configured()
        config = {"docs_dir": str(docs), "nav": None}
        p.on_config(config)
        assert "index.md" in [f.src_uri for f in p.on_files(files, config)]

    def test_message_for_one_page_and_for_many(self):
        assert "1 page names no audiences" in _untagged_message(["a.md"])
        many = _untagged_message([f"p{i}.md" for i in range(12)])
        assert "12 pages name no audiences" in many and "and 2 more" in many


class TestResolveRef:
    @pytest.mark.parametrize("base,ref,expected", [
        ("api/overview", "../../img/a.png", "img/a.png"),
        ("", "img/a.png", "img/a.png"),
        ("guide", "spec%20v2.pdf#page=2", "guide/spec v2.pdf"),
        ("guide", "/img/a.png", "img/a.png"),
        ("guide", "https://example.com/img/a.png", None),
        ("guide", "//cdn.example.com/x.js", None),
        ("guide", "mailto:a@example.com", None),
        ("guide", "#section", None),
        ("", "../outside.png", None),
    ])
    def test_cases(self, base, ref, expected):
        assert _resolve_ref(base, ref) == expected

    def test_site_url_is_part_of_the_site(self):
        assert _resolve_ref("a", "https://example.com/docs/img/x.png", "https://example.com/docs/") == "img/x.png"
        assert _resolve_ref("a", "/docs/img/x.png", "https://example.com/docs/") == "img/x.png"

    def test_css_refs(self):
        css = 'body{background:url("../img/bg.png")} @import "more.css"; a{b:url(x.woff2)}'
        assert _css_refs(css) == ["../img/bg.png", "more.css", "x.woff2"]


class TestNonPageFiles:
    """A full MkDocs build: a file goes into a build only if something in it uses it."""

    def _project(self, tmp_path):
        docs = tmp_path / "docs"
        for d in ("img", "files", "assets", "styles"):
            (docs / d).mkdir(parents=True)
        (docs / "index.md").write_text(
            "---\naudiences: [public, partner]\n---\n# Home\n\n![a](img/a.png)\n\n"
            '<a href="files/guide.pdf">guide</a>\n\n'
            "<!-- audience: internal -->\n![inside a marker](img/marked.png)\n<!-- /audience -->\n"
        )
        (docs / "internal.md").write_text(
            "---\naudiences: [internal]\n---\n# Internal\n\n![s](img/secret.png)\n\n[spec](files/spec.pdf)\n"
        )
        for name in ("img/a.png", "img/secret.png", "img/marked.png", "img/bg.png", "img/deep.png",
                     "files/guide.pdf", "files/spec.pdf", "files/orphan.txt", "CNAME", "assets/logo.svg"):
            (docs / name).write_bytes(b"x")
        (docs / "assets" / "site.css").write_text("body{background:url(../img/bg.png)}")
        (docs / "styles" / "extra.css").write_text('@import "more.css";')
        (docs / "styles" / "more.css").write_text("p{background:url('../img/deep.png')}")
        (tmp_path / "mkdocs.yml").write_text(
            "site_name: T\nsite_url: https://example.com/\ntheme: mkdocs\nstrict: true\n"
            "extra_css: [assets/site.css, styles/extra.css]\n"
            "plugins:\n  - audience:\n      audiences: [internal, partner, public]\n"
        )
        return tmp_path

    def _build(self, root, audience, monkeypatch):
        from mkdocs.commands.build import build
        from mkdocs.config import load_config
        monkeypatch.setenv(ENV_VAR, audience)
        monkeypatch.chdir(root)
        cfg = load_config(config_file=str(root / "mkdocs.yml"), site_dir=str(root / "site" / audience))
        build(cfg)
        out = root / "site" / audience
        return {p.relative_to(out).as_posix() for p in out.rglob("*") if p.is_file()}

    def test_public_gets_only_what_it_uses(self, tmp_path, monkeypatch):
        built = self._build(self._project(tmp_path), "public", monkeypatch)
        for kept in ("img/a.png", "files/guide.pdf", "CNAME", "assets/logo.svg", "assets/site.css",
                     "img/bg.png", "styles/extra.css", "styles/more.css", "img/deep.png"):
            assert kept in built, kept
        for left_out in ("img/secret.png", "files/spec.pdf", "img/marked.png", "files/orphan.txt"):
            assert left_out not in built, left_out

    def test_internal_gets_every_file(self, tmp_path, monkeypatch):
        built = self._build(self._project(tmp_path), "internal", monkeypatch)
        for name in ("img/secret.png", "files/spec.pdf", "img/marked.png", "files/orphan.txt"):
            assert name in built, name

    def test_left_out_files_are_logged(self, tmp_path, monkeypatch, caplog):
        import logging
        with caplog.at_level(logging.INFO):
            self._build(self._project(tmp_path), "partner", monkeypatch)
        assert any("left 4 files out of the 'partner' build" in r.getMessage() for r in caplog.records)

    # 0.4.1: a nav entry doesn't keep a file, and one for a file left out warns.

    def test_nav_entry_for_a_file_the_build_leaves_out_fails_strict(self, tmp_path, monkeypatch, caplog):
        import logging
        from mkdocs.exceptions import Abort
        root = self._project(tmp_path)
        with open(root / "mkdocs.yml", "a", encoding="utf-8") as f:
            f.write("nav:\n  - Home: index.md\n  - Internal: internal.md\n  - Spec: files/spec.pdf\n")
        assert "files/spec.pdf" in self._build(root, "internal", monkeypatch)
        with caplog.at_level(logging.WARNING), pytest.raises(Abort, match="strict mode"):
            self._build(root, "public", monkeypatch)
        assert any("the nav links to files/spec.pdf, but no page in the 'public' build" in r.getMessage()
                   for r in caplog.records)

    def test_nav_entry_for_a_file_the_build_keeps_is_fine(self, tmp_path, monkeypatch):
        root = self._project(tmp_path)
        with open(root / "mkdocs.yml", "a", encoding="utf-8") as f:
            f.write("nav:\n  - Home: index.md\n  - Guide: files/guide.pdf\n  - Logo: assets/logo.svg\n")
        assert {"files/guide.pdf", "assets/logo.svg"} <= self._build(root, "public", monkeypatch)
BD_TESTS_EOF
}

# ---------------------------------------------------------------------------
# Charter. The script doesn't carry the font. The first run downloads it from
# Matthew Butterick's Charter page at practicaltypography.com, the release
# that Arch Linux's AUR package (ttf-bitstream-charter) and Homebrew's cask
# (font-charter) both install, and uses it only if its sha256 is
# CHARTER_ZIP_SHA256, the value both of them record for that file: you can
# check it against either. With CHARTER_ZIP set (see the top of the script),
# the run reads a copy you saved instead. Copies of Charter's files, the ones
# v28 wrote among them, store the descender as a positive number. The run
# makes it negative with fontTools, which it installs into .venv for that
# alone, then writes the four woff2 files;
# docs/assets/fonts/LICENSE-Charter.txt says what changes. Later runs find
# the corrected files and download nothing.
# ---------------------------------------------------------------------------
CHARTER_URL="https://practicaltypography.com/fonts/Charter%20210112.zip"
CHARTER_ZIP_SHA256=b40297f1a615f94594bdad0995848eb2223fb53ccb4ea197cabf24439bd811c9
CHARTER_NAMES=(charter_regular charter_italic charter_bold charter_bold_italic)
# The sha256 of each file as v28 wrote it, uncorrected. A run corrects such a
# file where it is, without downloading anything.
declare -A CHARTER_V28_SHA256=(
    [charter_regular]=8e7630ba96aeed2974bfd9ab9bde2d3159e4cba98dfdb5757584e3542f1ac962
    [charter_italic]=35b98e2d6da9ab1d3580aee832f6d8ddfc3687cfd8e12e4e699aa202a70446e2
    [charter_bold]=ca7c6aadefb1318729ac10048a85faeb6eccfbe4d4ecb2e582747663a3915792
    [charter_bold_italic]=192919b5cdf9180894546fa74b206885b4e6af737189ec43601d382017f89b60
)

# True for one of Charter's files as v28 wrote it, uncorrected.
charter_uncorrected() {
    local name
    name=$(basename "$1" .woff2)
    [ -n "${CHARTER_V28_SHA256[$name]:-}" ] && [ -f "$1" ] \
        && [ "$(sha256_of "$1")" = "${CHARTER_V28_SHA256[$name]}" ]
}

# Write the corrected Charter files that are missing, or uncorrected as v28
# wrote them, then the notice that goes with them. A file this script
# corrected before is kept, so a re-run needs no network. Without network, or
# with a download that doesn't match, it writes none of them; until a later
# run does, a missing file leaves the site on the system's serif.
scaffold_fonts() {
    local name path todo=() existed=() download="" reason
    for name in "${CHARTER_NAMES[@]}"; do
        path="docs/assets/fonts/$name.woff2"
        if [ -e "$path" ] && ! charter_uncorrected "$path"; then
            if ! $REGENERATE_OWNED || [ "$(sha256_of "$path")" = "$(recorded_sha256 "$path")" ]; then
                # Corrected by an earlier run; the drift check vouched for it.
                OWNED_DONE["$path"]=1
                OWNED_SAME=$((OWNED_SAME + 1))
                continue
            fi
            # Edited, and this is a forced run: keep a copy. The file counts
            # as this run's once its replacement is written.
            backup_owned "$path"
        else
            # Missing, or as v28 wrote it: nothing to lose, so it counts as
            # this run's now, and a run cut off part-way records what it wrote.
            OWNED_DONE["$path"]=1
        fi
        if [ -e "$path" ]; then existed+=("$path"); fi
        if ! charter_uncorrected "$path"; then download=1; fi
        todo+=("$name=${CHARTER_V28_SHA256[$name]}")
    done
    if [ ${#todo[@]} -eq 0 ]; then
        write_charter_license
        return 0
    fi
    # What a failure note says: what didn't happen, and what the site uses
    # until a later run does it.
    local what="add Charter" meanwhile="Charter's files stay as they are until a later run corrects them."
    if [ -z "$download" ]; then
        what="correct Charter's files"
    fi
    for name in "${todo[@]}"; do
        if [ ! -e "docs/assets/fonts/${name%%=*}.woff2" ]; then
            meanwhile="The site uses your system's serif font until a later run adds Charter."
        fi
    done
    if ! .venv/bin/python -c 'import fontTools, brotli' >/dev/null 2>&1; then
        pick_installer
        if ! venv_install fonttools brotli; then
            note "couldn't install fontTools, which corrects Charter's files (offline?).
  $meanwhile"
            return 0
        fi
        log "installed fontTools and brotli in .venv, to correct Charter's files"
    fi
    local source="$CHARTER_URL"
    if [ -z "$download" ]; then
        log "correcting Charter's files, which v28 wrote uncorrected"
    elif [ -n "$CHARTER_ZIP" ]; then
        source="$CHARTER_ZIP"
        log "adding Charter: reading $(shown "$CHARTER_ZIP"), checking and correcting it"
    else
        log "adding Charter: downloading it from practicaltypography.com, checking and correcting it"
    fi
    if ! reason=$(.venv/bin/python - "$source" "$CHARTER_ZIP_SHA256" docs/assets/fonts "${todo[@]}" \
                  2>&1 >/dev/null <<'BD_CHARTER_PY_EOF'
"""Write corrected copies of the Charter files named in the arguments.

Arguments: SOURCE ZIP_SHA256 FOLDER NAME=SHA256...
A file in FOLDER that matches its SHA256, as v28 wrote it, is corrected where
it is. The others come from the zip at SOURCE, a URL or a saved copy, used
only if it matches ZIP_SHA256: its woff2 files, or its TTF files if it lacks
a woff2 copy of a style, each recognised by the family and style it names,
not by its file name. Every file is corrected and checked before any is
written. Exit status 3, with one line on stderr, when none is written.
"""
import hashlib
import io
import os
import sys
import urllib.request
import zipfile
from pathlib import Path

from fontTools.ttLib import TTFont

source, zip_sha256, folder = sys.argv[1], sys.argv[2], Path(sys.argv[3])
wanted = dict(arg.split("=", 1) for arg in sys.argv[4:])
STYLES = {"Regular": "charter_regular", "Italic": "charter_italic",
          "Bold": "charter_bold", "Bold Italic": "charter_bold_italic"}
LARGEST = 10_000_000  # bytes read at most; the release is about 240 KB
# Charter's line metrics as extra.css and LICENSE-Charter.txt give them: units
# per em; hhea ascender, descender and line gap; OS/2 typographic descender;
# Windows ascent and descent.
METRICS = (1000, 980, -236, 0, -240, 980, 236)


def sha256(data):
    return hashlib.sha256(data).hexdigest()


def fail(message):
    print(message, file=sys.stderr)
    sys.exit(3)


def styles_in(archive, suffix):
    """The zip's Charter fonts with this suffix, by the name this site gives them."""
    found = {}
    for item in sorted(archive.infolist(), key=lambda item: item.filename):
        if not item.filename.lower().endswith(suffix):
            continue
        try:
            names = TTFont(io.BytesIO(archive.read(item)))["name"]
        except Exception:
            continue  # not a font, such as the ._ files a Mac adds to a zip
        family, style = names.getDebugName(1), names.getDebugName(2)
        if family == "Charter" and style in STYLES:
            found.setdefault(STYLES[style], archive.read(item))
    return found


def seen(font):
    """What a reader sees: glyph outlines, character map, names."""
    glyf = font["glyf"]
    outlines = {g: glyf[g].getCoordinates(glyf)[0].array.tolist() if glyf[g].numberOfContours else []
                for g in font.getGlyphOrder()}
    cmaps = sorted((t.platformID, t.platEncID, tuple(sorted(t.cmap.items()))) for t in font["cmap"].tables)
    names = sorted((n.platformID, n.nameID, n.toUnicode()) for n in font["name"].names)
    return outlines, cmaps, names


sources = {}
for name, digest in wanted.items():
    path = folder / f"{name}.woff2"
    if path.is_file() and sha256(path.read_bytes()) == digest:
        sources[name] = path.read_bytes()
missing = [name for name in wanted if name not in sources]
if missing:
    if source.startswith(("https://", "http://")):
        request = urllib.request.Request(source, headers={"User-Agent": "bootstrap-docs"})
        try:
            with urllib.request.urlopen(request, timeout=60) as response:
                data = response.read(LARGEST + 1)
        except Exception as exc:
            fail(f"couldn't download {source} ({exc})")
    else:
        try:
            with open(source, "rb") as saved:
                data = saved.read(LARGEST + 1)
        except OSError as exc:
            fail(f"couldn't read {source} ({exc.strerror})")
    if sha256(data) != zip_sha256:
        fail(f"{source} isn't the file this script expects: its sha256 differs")
    # The checksum vouches for the zip, so its contents are safe to read.
    with zipfile.ZipFile(io.BytesIO(data)) as archive:
        found = styles_in(archive, ".woff2")
        if not all(name in found for name in missing):
            found = styles_in(archive, ".ttf")
    if not all(name in found for name in missing):
        fail(f"{source} has no woff2 or TTF copy of every Charter style")
    for name in missing:
        sources[name] = found[name]

corrected = {}
for name, raw in sources.items():
    font = TTFont(io.BytesIO(raw), recalcBBoxes=False, recalcTimestamp=False)
    font["hhea"].descent = -abs(font["hhea"].descent)
    font["OS/2"].sTypoDescender = -abs(font["OS/2"].sTypoDescender)
    head, hhea, os2 = font["head"], font["hhea"], font["OS/2"]
    if (head.unitsPerEm, hhea.ascent, hhea.descent, hhea.lineGap, os2.sTypoDescender,
            os2.usWinAscent, os2.usWinDescent) != METRICS:
        fail(f"{name}.woff2 doesn't have the line metrics extra.css expects for Charter")
    font.flavor = "woff2"
    buffer = io.BytesIO()
    font.save(buffer)
    if seen(TTFont(io.BytesIO(raw))) != seen(TTFont(io.BytesIO(buffer.getvalue()))):
        fail(f"correcting {name}.woff2 changed more than its descender values")
    corrected[name] = buffer.getvalue()

folder.mkdir(parents=True, exist_ok=True)
for name, data in corrected.items():
    part = folder / f".{name}.woff2.part"  # hidden, so MkDocs never copies it
    part.write_bytes(data)
    os.replace(part, folder / f"{name}.woff2")
BD_CHARTER_PY_EOF
    ); then
        note "couldn't $what: ${reason##*$'\n'}
  $meanwhile"
        return 0
    fi
    for name in "${todo[@]}"; do
        path="docs/assets/fonts/${name%%=*}.woff2"
        OWNED_DONE["$path"]=1
        if [[ " ${existed[*]} " == *" $path "* ]]; then
            OWNED_UPDATED=$((OWNED_UPDATED + 1))
            log "updated $path"
        else
            OWNED_WRITTEN=$((OWNED_WRITTEN + 1))
            log "wrote $path"
        fi
    done
    write_charter_license
}

# The notice that goes with Charter's files. scaffold_fonts writes it once the
# four files are corrected, as it says they are.
write_charter_license() {
    write_owned docs/assets/fonts/LICENSE-Charter.txt <<'BD_FONT_LICENSE_EOF'
Charter, the typeface in charter_regular.woff2, charter_italic.woff2,
charter_bold.woff2 and charter_bold_italic.woff2, is distributed under this
notice, which is also embedded in each of those files:

(c) Copyright 1989-1992, Bitstream Inc., Cambridge, MA. You are hereby granted permission under all Bitstream propriety rights to use, copy, modify, sublicense, sell, and redistribute the 4 Bitstream Charter (r) Type 1 outline fonts and the 4 Courier Type 1 outline fonts for any purpose and without restriction; provided, that this notice is left intact on all copies of such fonts and that Bitstream's trademark is acknowledged as shown below on all unmodified copies of the 4 Charter Type 1 fonts: 'BITSTREAM CHARTER is a registered trademark of Bitstream Inc.'

BITSTREAM CHARTER is a registered trademark of Bitstream Inc.

bootstrap-docs writes these four files, from Charter's release at
practicaltypography.com/charter.html or from the copies its v28 wrote, with
each one's hhea descender and OS/2 typographic descender negative (-236 and
-240). Copies of Charter's files, the ones v28 wrote among them, store those
two values as positive numbers (236 and 240), which a browser can read as
lying above the baseline (Chromium does), working out too small a line
height; bootstrap-docs makes them negative. The line height then matches the
files' Windows metrics (980 above the baseline, 236 below, per 1000). Nothing
else is changed: the outlines, character map, names and the notice above are
as they were.
BD_FONT_LICENSE_EOF
}

scaffold_design() {
    write_owned docs/assets/styles/extra.css <<'BD_EXTRA_CSS_EOF'
/* audience-docs theme for Material for MkDocs.

   Four rules run through this file:
   1. Material's teal and deep orange are the site's colours: the header,
      links, the current page in the navigation, hover and focus. The
      palette in mkdocs.yml names them, and Material paints with them.
   2. The audience colours appear only where an audience is meant: the build
      band and the review marks in the internal build.
   3. Reading first. One serif, Charter, for everything but code, set at a
      measure of about 75 characters with generous leading.
   4. Hairlines, not boxes. No shadows, no filled pills, no tinted panels
      except where they carry meaning.

   Material reads its colours from CSS variables. This file sets the ones
   for text and grounds, in both schemes, and leaves the teal and orange to
   Material. The selectors are attribute selectors on body, the element
   Material puts them on, and this file loads after Material's, so they win
   at equal specificity.

   bootstrap-docs owns this file and rewrites it when you re-run the script.
   For your own rules, add a stylesheet of your own next to it and list it
   under extra_css in mkdocs.yml, after this one. */

/* ---------------------------------------------------------------- fonts
   Charter (Bitstream, free to use, modify and redistribute: the notice is in
   each file and in ../fonts/LICENSE-Charter.txt). Four files, about 61 KB in
   all, served from this site: no third-party font request. bootstrap-docs
   downloads them from Matthew Butterick's Charter page on its first run and
   corrects their descender values; that file says how. The three overrides
   on each face pin the same values, so an uncorrected copy still sets its
   lines right in Chromium and Firefox; on corrected files they change
   nothing. Until the files arrive, the stack below falls back to the
   system's serif. */
@font-face { font-family: "Charter"; src: url("../fonts/charter_regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; ascent-override: 98%; descent-override: 23.6%; line-gap-override: 0%; }
@font-face { font-family: "Charter"; src: url("../fonts/charter_italic.woff2") format("woff2"); font-weight: 400; font-style: italic; font-display: swap; ascent-override: 98%; descent-override: 23.6%; line-gap-override: 0%; }
@font-face { font-family: "Charter"; src: url("../fonts/charter_bold.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; ascent-override: 98%; descent-override: 23.6%; line-gap-override: 0%; }
@font-face { font-family: "Charter"; src: url("../fonts/charter_bold_italic.woff2") format("woff2"); font-weight: 700; font-style: italic; font-display: swap; ascent-override: 98%; descent-override: 23.6%; line-gap-override: 0%; }

body {
  --md-text-font-family: "Charter", "Bitstream Charter", Palatino, "Palatino Linotype",
    "Book Antiqua", "Noto Serif", "Liberation Serif", Georgia, serif;
  --md-code-font-family: ui-monospace, "SFMono-Regular", "DejaVu Sans Mono",
    "Liberation Mono", Menlo, Consolas, monospace;
}

/* ---------------------------------------------------------------- tokens */
[data-md-color-scheme="default"] {
  --paper: #ffffff;
  --ink: #1d1e20;      /* text */
  --ink-2: #4a4e55;    /* secondary text: navigation, captions */
  --ink-3: #737881;    /* tertiary: labels, placeholders */
  --rule: #e3e5e8;     /* hairlines */
  --wash: #f5f6f7;     /* code ground */
  --aud-internal: #7b3fa8;
  --aud-partner: #1f62ad;
  --aud-beta: #965a08;
  --aud-public: #22724a;
  --aud-other: var(--ink-2);   /* an audience you add yourself */
}

[data-md-color-scheme="slate"] {
  --paper: #17181b;
  --ink: #e6e7e9;
  --ink-2: #b6b9bf;
  --ink-3: #888d95;
  --rule: #2d2f34;
  --wash: #202226;
  --aud-internal: #c59cea;
  --aud-partner: #8cb8f2;
  --aud-beta: #e5b160;
  --aud-public: #80c89c;
  --aud-other: var(--ink-2);
}

/* The banner sits on a solid band, so it uses the same deep colours in both
   schemes; white text on each is above 7:1. --band-other is for an audience
   you add yourself. */
[data-md-color-scheme] {
  --band-internal: #6b2f99;
  --band-partner: #1b579b;
  --band-beta: #7f4a03;
  --band-public: #1c603e;
  --band-other: #3b3e44;
}

[data-md-color-scheme] {
  --md-default-fg-color: var(--ink);
  --md-default-fg-color--light: var(--ink-2);
  --md-default-fg-color--lighter: var(--ink-3);
  --md-default-fg-color--lightest: var(--rule);
  --md-default-bg-color: var(--paper);
  --md-default-bg-color--light: var(--paper);
  --md-typeset-color: var(--ink);
  --md-typeset-table-color: var(--rule);
  --md-typeset-table-color--light: var(--wash);
  --md-typeset-mark-color: var(--wash);
  --md-typeset-mark-color: color-mix(in srgb, var(--ink) 14%, transparent);
  --md-code-fg-color: var(--ink);
  --md-code-bg-color: var(--wash);
  --md-code-bg-color--light: var(--wash);     /* behind the copy button */
  --md-code-bg-color--lighter: var(--wash);
  --md-code-hl-color: var(--ink-3);
  --md-code-hl-color--light: var(--wash);
  --md-code-hl-color--light: color-mix(in srgb, var(--ink) 8%, transparent);
  /* Code is set in ink: comments recede, strings step back, and the rest
     is plain. */
  --md-code-hl-number-color: var(--ink);
  --md-code-hl-special-color: var(--ink);
  --md-code-hl-function-color: var(--ink);
  --md-code-hl-constant-color: var(--ink);
  --md-code-hl-keyword-color: var(--ink);
  --md-code-hl-string-color: var(--ink-2);
  --md-code-hl-name-color: var(--ink);
  --md-code-hl-operator-color: var(--ink-2);
  --md-code-hl-punctuation-color: var(--ink-2);
  --md-code-hl-comment-color: var(--ink-3);
  --md-code-hl-generic-color: var(--ink-2);
  --md-code-hl-variable-color: var(--ink);
  --md-typeset-kbd-color: var(--wash);
  --md-typeset-kbd-accent-color: var(--paper);
  --md-typeset-kbd-border-color: var(--rule);
  --md-admonition-fg-color: var(--ink);
  --md-admonition-bg-color: var(--paper);
  --md-footer-fg-color: var(--ink);
  --md-footer-fg-color--light: var(--ink-2);
  --md-footer-fg-color--lighter: var(--ink-3);
  --md-footer-bg-color: var(--paper);
  --md-footer-bg-color--dark: var(--paper);
  --md-shadow-z1: none;
  --md-shadow-z2: 0 0 0 1px var(--rule);
  --md-shadow-z3: 0 0 0 1px var(--rule);
}

/* Links on paper take Material's darker teal: its header teal, #009485, is
   3.8:1 on white, under the 4.5:1 that body text needs; #007a6c is 5.3:1.
   The dark scheme keeps Material's own light teal. */
[data-md-color-scheme="default"][data-md-color-primary] {
  --md-typeset-a-color: var(--md-primary-fg-color--dark);
}

/* bootstrap-docs v28 wrote "primary: custom" and "accent: custom" into
   mkdocs.yml, which leaves Material's colours to this file. A project it
   made gets Material's teal and deep orange here, with Material's values. */
[data-md-color-primary="custom"] {
  --md-primary-fg-color: #009485;
  --md-primary-fg-color--light: #26a699;
  --md-primary-fg-color--dark: #007a6c;
  --md-primary-bg-color: #ffffff;
  --md-primary-bg-color--light: #ffffffb3;
}

[data-md-color-scheme="slate"][data-md-color-primary="custom"] {
  --md-typeset-a-color: #00ccb8;
}

[data-md-color-accent="custom"] {
  --md-accent-fg-color: #ff6e42;
  --md-accent-fg-color--transparent: #ff6e421a;
  --md-accent-bg-color: #ffffff;
  --md-accent-bg-color--light: #ffffffb3;
}

/* ---------------------------------------------------------------- header
   Material's teal bar, flat: no shadow when the page scrolls under it. */
.md-header,
.md-header--shadow {
  box-shadow: none;
}

.md-header__topic:first-child {
  font-weight: 700;
}

/* The logo is the favicon, green bars, with nothing behind them. The
   favicon's green is 1.4:1 on the teal bar and all but disappears, so the
   header and the menu show it at half brightness, a darker green that is
   3.2:1. A logo of your own, in a file with another name, shows as it is. */
.md-logo img[src$="assets/favicon.svg"] {
  filter: brightness(0.5);
}

/* ---------------------------------------------------------------- build banner
   A slim band across the top in the build's colour, so nobody mistakes
   which build they are reading. overrides/main.html writes the band's text,
   and sets its colour for the build being made. The inner box is also
   .md-typeset, so the size is set on both classes at once. */
.md-banner {
  color: #ffffff;
  background-color: var(--band-internal);
}

.md-banner .md-banner__inner {
  margin: 0.25rem auto;
  font-size: 0.62rem;
  line-height: 1.4;
}

.md-banner .build-note {
  margin: 0;
}

.md-banner .build-note strong {
  font-weight: 700;
}

/* ---------------------------------------------------------------- layout */
.md-main__inner {
  margin-top: 1.6rem;
}

/* Start both side columns on the line the page title's capitals start on. */
.md-sidebar {
  padding-top: 0.8rem;
}

.md-content__inner {
  max-width: 31rem;
}

/* ---------------------------------------------------------------- navigation */
.md-nav {
  font-size: 0.72rem;
  line-height: 1.4;
}

.md-nav__link {
  margin-top: 0.5em;
  color: var(--ink-2);
}

/* A section's own entry is darker than the pages under it. The current
   page is Material's teal, in bold; hover is Material's orange. */
.md-nav__item--nested > .md-nav__link {
  color: var(--ink);
}

.md-nav__item .md-nav__link--active,
.md-nav__item .md-nav__link--active code {
  font-weight: 700;
}

.md-nav__icon {
  color: var(--ink-3);
}

/* The left column needs no heading of its own on wide screens, where the
   header already names the site; the table of contents gets a quiet one so
   the two lists aren't confused. Material's own label for it reads "Table of
   contents". */
@media screen and (min-width: 76.25em) {
  .md-sidebar--primary .md-nav--primary > .md-nav__title {
    display: none;
  }
}

.md-sidebar--secondary .md-nav__title {
  font-size: 0;
  color: var(--ink-3);
  padding-top: 0.35rem;
  padding-bottom: 0;
}

.md-sidebar--secondary .md-nav__title::after {
  content: "On this page";
  font-size: 0.68rem;
  font-weight: 400;
  font-style: italic;
}

/* ---------------------------------------------------------------- reading */
.md-typeset {
  font-size: 0.9rem;
  line-height: 1.65;
  font-variant-numeric: oldstyle-nums;
}

.md-typeset h1 {
  font-size: 2.05em;
  font-weight: 400;
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--ink);
  margin: 0 0 0.7em;
}

.md-typeset h2 {
  font-size: 1.38em;
  font-weight: 700;
  line-height: 1.25;
  margin: 1.9em 0 0.55em;
}

.md-typeset h3 {
  font-size: 1.12em;
  font-weight: 700;
  line-height: 1.3;
  margin: 1.6em 0 0.45em;
}

.md-typeset h4 {
  font-size: 1em;
  font-weight: 700;
  font-style: italic;
  margin: 1.4em 0 0.4em;
}

.md-typeset h5,
.md-typeset h6 {
  font-size: 0.9em;
  font-weight: 700;
  color: var(--ink-2);
  text-transform: none;
}

.md-typeset :is(ol, ul) li {
  margin-bottom: 0.3em;
}

.md-typeset hr {
  border-bottom-color: var(--rule);
  margin: 2.2em 0;
}

[dir] .md-typeset blockquote {
  border-left: 2px solid var(--rule);
  color: var(--ink-2);
  font-style: italic;
  padding-left: 1em;
}

/* ---------------------------------------------------------------- code */
.md-typeset code,
.md-typeset kbd {
  font-size: 0.82em;
  font-variant-numeric: normal;
}

.md-typeset :not(pre) > code {
  background-color: var(--wash);
  box-shadow: inset 0 0 0 1px var(--rule);
  border-radius: 0.15rem;
  padding: 0.05em 0.35em;
}

.md-typeset pre > code {
  border-radius: 0.15rem;
  box-shadow: inset 0 0 0 1px var(--rule);
}

.highlight .c,
.highlight .c1,
.highlight .ch,
.highlight .cm,
.highlight .cs {
  font-style: italic;
}

/* ---------------------------------------------------------------- tables */
.md-typeset table:not([class]) {
  border: 0;
  border-radius: 0;
  box-shadow: none;
  font-size: 0.9em;
}

.md-typeset table:not([class]) th,
.md-typeset table:not([class]) td {
  padding: 0.55em 1.2em 0.55em 0;
}

.md-typeset table:not([class]) th {
  font-weight: 700;
  border-bottom: 1px solid var(--ink-3);
  background: none;
}

.md-typeset table:not([class]) td {
  border-top: 1px solid var(--rule);
}

.md-typeset table:not([class]) tbody tr:hover {
  background-color: var(--wash);
  box-shadow: none;
}

/* ---------------------------------------------------------------- admonitions
   A hairline frame and Material's own icon in its own colour: the type of
   note is worth a colour, the box around it is not. */
.md-typeset .admonition[class],
.md-typeset details[class] {
  border-width: 1px;
  border-color: var(--rule);
  border-radius: 0.15rem;
  box-shadow: none;
  font-size: 0.9em;
}

.md-typeset .admonition-title,
.md-typeset summary {
  background-color: var(--wash) !important;
  font-weight: 700;
}

/* ---------------------------------------------------------------- footer */
.md-footer {
  border-top: 1px solid var(--rule);
}

.md-footer__title {
  line-height: 1.3;
}

.md-footer__direction {
  color: var(--ink-3);
  font-style: italic;
  line-height: 1.3;
}

.md-footer-meta {
  border-top: 1px solid var(--rule);
}

/* Material lays this row out with the copyright on the left and social
   links on the right. With none, the right is empty and the copyright looks
   off-centre, so the row is centred. */
.md-footer-meta__inner {
  justify-content: center;
}

.md-copyright {
  margin: auto;
  text-align: center;
}

.md-copyright,
.md-copyright__highlight {
  color: var(--ink-3);
}

.md-copyright a {
  color: var(--ink-2);
}

/* ---------------------------------------------------------------- review marks
   Only the internal build has these: the plugin wraps what it keeps there,
   and nowhere else. A block gets a rule down its left side in the colour of
   the widest audience that also receives it, public over beta over partner,
   and a note above it naming every one. Content no other build receives is
   marked in internal's colour and noted "Internal only"; content only an
   audience you added yourself receives is marked in grey. Inline marks get
   a tint instead, and name their audiences when you point at them. */
.md-typeset .audience-block,
.md-typeset .audience-inline {
  --mark: var(--aud-other);
}

.md-typeset [data-reach=""] { --mark: var(--aud-internal); }
.md-typeset [data-reach~="partner"] { --mark: var(--aud-partner); }
.md-typeset [data-reach~="beta"] { --mark: var(--aud-beta); }
.md-typeset [data-reach~="public"] { --mark: var(--aud-public); }

.md-typeset .audience-block {
  margin: 1.4em 0;
  padding: 0.1em 0 0.1em 1rem;
  border-left: 2px solid var(--mark);
}

.md-typeset .audience-block::before {
  content: attr(data-label);
  display: block;
  margin-bottom: 0.15em;
  font-size: 0.82em;
  font-style: italic;
  color: var(--mark);
}

.md-typeset .audience-block > :first-child { margin-top: 0; }
.md-typeset .audience-block > :last-child { margin-bottom: 0; }

.md-typeset .audience-inline {
  background-color: var(--wash);
  background-color: color-mix(in srgb, var(--mark) 13%, transparent);
  border-bottom: 1px solid var(--mark);
  border-bottom: 1px solid color-mix(in srgb, var(--mark) 60%, transparent);
  padding: 0 0.12em;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

/* ---------------------------------------------------------------- print
   Material leaves the banner off printed pages. Here it stays, in black, so
   a page printed from the internal, partner or beta build still says which
   build it came from. The doubled class outranks the band colour that
   overrides/main.html sets. */
@media print {
  .md-banner.md-banner {
    display: block;
    color: #000;
    background: none;
    border-bottom: 1px solid #000;
  }
  .md-typeset .audience-block::before { color: #000; }
}
BD_EXTRA_CSS_EOF
    write_owned docs/assets/favicon.svg <<'BD_FAVICON_EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00796b">
  <rect x="3" y="3" width="18" height="3" rx="1"/>
  <rect x="5" y="8" width="14" height="3" rx="1"/>
  <rect x="7" y="13" width="10" height="3" rx="1"/>
  <rect x="9" y="18" width="6" height="3" rx="1"/>
</svg>
BD_FAVICON_EOF
    scaffold_fonts
    if owns_nav_titles; then
        write_owned docs/assets/styles/nav-titles.css <<'BD_NAV_TITLES_EOF'
/* Kept empty on purpose. bootstrap-docs v27 and earlier put the sidebar
   titles here; since v28, extra.css does it. An older mkdocs.yml still lists
   this file under extra_css, so it stays: delete it together with that line
   if you like. */
BD_NAV_TITLES_EOF
    fi
}

scaffold_overrides() {
    write_owned overrides/main.html <<'BD_MAIN_HTML_EOF'
{% extends "base.html" %}

{#- Build banner. Every build except public says which audience it was built
    for, on a band in that audience's colour, using Material's documented
    `announce` block. The band's colour is set in `extrahead`: from the
    variables extra.css defines, or, without that file, from the colours
    written here. An audience you add yourself gets a grey band. Nothing
    renders for the public build. -#}
{% block extrahead -%}
  {{ super() }}
  {%- set audience = config.extra.audience -%}
  {%- if audience and audience != "public" %}
  {%- set band = {"internal": "#6b2f99", "partner": "#1b579b", "beta": "#7f4a03"}.get(audience, "#3b3e44") %}
  <style>.md-banner{background-color:var(--band-{{ audience }},var(--band-other,{{ band }}));color:#fff}</style>
  {%- endif %}
{%- endblock %}

{% block announce -%}
  {%- set audience = config.extra.audience -%}
  {%- if audience and audience != "public" -%}
    <p class="build-note" data-build="{{ audience }}"><strong>{{ audience | capitalize }} build.</strong> Not for public release.</p>
  {%- endif -%}
{%- endblock %}
BD_MAIN_HTML_EOF
}

scaffold_leak_check() {
    write_owned bin/check-leaks.py <<'BD_CHECKER_EOF'
#!/usr/bin/env python3
"""Build every declared audience; verify nothing leaked and no link is broken.

First it reads every page's metadata, as MkDocs finds it, and stops if any
page's tagging can't be trusted: frontmatter that isn't valid YAML, an
`audiences:` value that is empty or not a list or string, a key that is almost
`audiences`, or a line near the top that looks like a tag MkDocs doesn't read.
With `untagged: error`, the plugin's default, a page that names no audiences
stops it too.

Then, for each audience declared in mkdocs.yml, it:
  1. builds the site with --strict (no --quiet: warnings must fail the build)
  2. checks that no raw or misspelt audience marker survived into any file
  3. checks that no page source (a .MD file, an editor backup) was copied in
  4. checks that every page excluded for that audience is absent from the output
  5. checks that no output page links to an excluded page
  6. checks that the search index lists no excluded page
  7. outside the internal build, checks that every file copied from docs/ that
     isn't a page is used by the build: linked from one of its pages or
     stylesheets, or one of the files every build gets (assets/, CNAME,
     robots.txt, favicon.ico, hidden files)
  8. checks that every link within the site resolves in the build, however
     it's written: each URL a page points at (href, src, srcset, poster or
     data) has to lead to a file in the output, a link to a folder to its
     index.html, and a #fragment to an element with that id (or an <a> with
     that name) on the target page; # and #top mean the top of the page. Full
     URLs, even ones that start with site_url, and mailto:, tel:, javascript:
     and data: URLs aren't checked.

Checks 2 to 8 read only the built output, docs/ and the page frontmatter, so
they also catch a builder that ignored the plugin (Zensical does this
silently). To verify output from another builder, build each audience into
DIST/<audience>/ yourself and run this script with --no-build.

The page rule is re-implemented here on purpose, independently of the plugin,
so a bug in the plugin cannot also hide itself from this check. mkdocs.yml is
read with MkDocs's own loader, so the audience list is the one MkDocs uses.

Usage:
  bin/check-leaks.py                      # build all audiences into dist/, verify
  bin/check-leaks.py --builder properdocs # build with ProperDocs instead of MkDocs
  bin/check-leaks.py --no-build           # verify existing dist/<audience>/ only

Expected output when everything is clean (one line per audience, then a summary):
  [check-leaks] internal: built; 6 pages checked; clean
  [check-leaks] partner: built; 6 pages checked (3 excluded); clean
  [check-leaks] beta: built; 6 pages checked (3 excluded); clean
  [check-leaks] public: built; 6 pages checked (4 excluded); clean
  [check-leaks] all audiences clean
(Those counts are for the default demo project; yours grow with your pages.)
Exit status: 0 clean; 1 a build failed, something leaked or a link is broken;
2 configuration error.
"""

from __future__ import annotations

import argparse
import json
import os
import posixpath
import re
import shutil
import subprocess
import sys
from html.parser import HTMLParser
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit

ROOT = Path(__file__).resolve().parent.parent
VENV = ROOT / ".venv"

# Re-run inside the project's virtualenv if we were started from outside it,
# so PyYAML, MkDocs and the builder are the project's own versions.
_venv_python = VENV / "bin" / "python"
if (
    _venv_python.exists()
    and Path(sys.prefix).resolve() != VENV.resolve()
    and os.environ.get("CHECK_LEAKS_REEXEC") != "1"
):
    os.environ["CHECK_LEAKS_REEXEC"] = "1"
    os.execv(str(_venv_python), [str(_venv_python), str(Path(__file__).resolve()), *sys.argv[1:]])

try:
    import yaml
except ImportError:
    print("[check-leaks] error: PyYAML is not available. Run this inside the project's "
          "virtualenv, or install the project first: pip install -r requirements.txt",
          file=sys.stderr)
    sys.exit(2)

PRIVILEGED = "internal"
DOC_SUFFIXES = (".md", ".markdown", ".mdown", ".mkdn", ".mkd")
# Any comment that starts with a word; one whose word is almost "audience" is a
# marker the plugin should have removed, or a misspelt one it never saw.
COMMENT_WORD_RE = re.compile(r"<!--+\s*/?\s*(?P<word>[A-Za-z]+)")
# A page source that ended up in the output as a file: an upper-case extension
# MkDocs doesn't treat as a page, or an editor's backup of a page.
SOURCE_COPY_RE = re.compile(
    r"(?i)(\.(md|markdown|mdown|mkdn|mkd)(\.(bak|orig|old|tmp|swp))?$|~$|^#.*#$)"
)
BINARY_SUFFIXES = {
    ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".otf",
    ".eot", ".pdf", ".zip", ".gz", ".tgz", ".bz2", ".xz", ".mp4", ".webm", ".mp3", ".ogg",
    ".wav", ".avif", ".bmp", ".tif", ".tiff",
}
# How MkDocs 1.x finds page metadata (mkdocs/utils/meta.py): a YAML block, or
# else MultiMarkdown-style "key: value" lines at the top of the page.
YAML_RE = re.compile(r"^-{3}[ \t]*\n(.*?\n)(?:\.{3}|-{3})[ \t]*\n", re.DOTALL)
META_RE = re.compile(r"^[ ]{0,3}(?P<key>[A-Za-z0-9_-]+):\s*(?P<value>.*)")
META_MORE_RE = re.compile(r"^([ ]{4}|\t)(\s*)(?P<value>.*)")
AUDIENCE_LINE_RE = re.compile(r"""^\s*(?:[-*]\s*)?["']?(?P<key>[A-Za-z][\w-]*)["']?\s*(?:[:=]|\[)""")
FENCE_RE = re.compile(
    r"(?ms)"
    r"(?P<fenced>^[ ]{0,3}(?P<fence>```+|~~~+)[^\n]*\n.*?^[ ]{0,3}(?P=fence)[ \t]*$)"
    r"|"
    r"(?P<inline>`[^`\n]*`)",
)
FILENAME_AUDIENCE_RE = re.compile(r"^_(?P<audience>[a-z]+)-")
FILENAME_NEAR_RE = re.compile(r"^_[A-Za-z]+[-_]")


class _TolerantLoader(yaml.SafeLoader):
    """SafeLoader that ignores tags it doesn't know (!!python/name, !ENV, ...)."""


_TolerantLoader.add_multi_constructor("", lambda loader, suffix, node: None)


def log(msg: str) -> None:
    print(f"[check-leaks] {msg}", flush=True)


def load_settings(config_file: Path) -> dict:
    """The audience settings, as MkDocs resolves them (!ENV, INHERIT and the rest)."""
    os.environ.setdefault("NO_MKDOCS_2_WARNING", "true")
    try:
        from mkdocs.config import load_config
    except ImportError:
        log("error: MkDocs is not installed here. Install the project first: "
            "pip install -r requirements.txt")
        sys.exit(2)
    try:
        cfg = load_config(config_file=str(config_file))
    except (Exception, SystemExit) as exc:  # MkDocs raises Abort on a configuration error
        log(f"error: cannot read {config_file}: {exc}")
        sys.exit(2)
    plugin = cfg["plugins"].get("audience")
    if plugin is None:
        log(f"error: no 'audience' plugin in {config_file}; nothing to check")
        sys.exit(2)
    audiences = [str(a) for a in plugin.config["audiences"]]
    if not audiences:
        log(f"error: the audience plugin's 'audiences:' list in {config_file} is empty")
        sys.exit(2)
    return {
        "audiences": audiences,
        "filename_convention": bool(plugin.config["filename_convention"]),
        # A plugin older than 0.4.0 has no such setting and built untagged pages for public.
        "untagged": str(plugin.config.get("untagged", "public")),
        "docs_dir": Path(cfg["docs_dir"]).resolve(),
        "use_directory_urls": bool(cfg["use_directory_urls"]),
        "site_url": str(cfg["site_url"] or ""),
        "config": cfg,
    }


def edit_distance(a: str, b: str) -> int:
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def audiences_like(key: str) -> bool:
    """A key that is almost 'audiences', such as 'audience' or 'audeinces'."""
    key = key.lower()
    return key != "audiences" and edit_distance(key, "audiences") <= 2


def audience_line(line: str) -> bool:
    """A line that looks like it tags a page: 'audiences: ...', '- audience: ...', and so on."""
    m = AUDIENCE_LINE_RE.match(line)
    return bool(m) and (m.group("key").lower() == "audiences" or audiences_like(m.group("key")))


def marker_like(word: str) -> bool:
    word = word.lower()
    return word.startswith("audience") or edit_distance(word, "audience") <= 2


def code_checker(text: str):
    regions = [(m.start(), m.end()) for m in FENCE_RE.finditer(text)]
    return lambda pos: any(start <= pos < end for start, end in regions)


def stray_audience_line(text: str, lines: int = 60) -> str | None:
    """The first line near the top of an untagged page, outside code, that looks
    like a tag; MkDocs reads no audiences from such a page."""
    in_code = code_checker(text)
    offset = 0
    for n, line in enumerate(text.split("\n")):
        if n >= lines:
            break
        if not in_code(offset) and audience_line(line):
            return line.strip()
        offset += len(line) + 1
    return None


def read_meta(text: str) -> tuple[dict, str | None]:
    """The page's metadata as MkDocs reads it, and a problem if it can't be read."""
    m = YAML_RE.match(text)
    if m:
        try:
            data = yaml.load(m.group(1), Loader=_TolerantLoader)
        except yaml.YAMLError as exc:
            if not any(audience_line(line) for line in m.group(1).split("\n")):
                return {}, None  # MkDocs shows it as text; it names no audiences
            return {}, f"frontmatter is not valid YAML: {getattr(exc, 'problem', None) or exc}"
        return (data if isinstance(data, dict) else {}), None
    data: dict = {}
    key = None
    for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
        if not line.strip():
            break
        m1 = META_RE.match(line)
        if m1:
            key = m1.group("key").lower().strip()
            value = m1.group("value").strip()
            data[key] = f"{data[key]} {value}" if key in data else value
            continue
        m2 = META_MORE_RE.match(line)
        if m2 and key:
            data[key] += " " + m2.group("value").strip()
            continue
        break
    return data, None


def page_audiences(path: Path, filename_convention: bool) -> tuple[list[str] | None, str | None]:
    """(audiences the page is tagged for or None if untagged, problem or None)."""
    names: list[str] = []
    if filename_convention:
        m = FILENAME_AUDIENCE_RE.match(path.name)
        if m:
            names.append(m.group("audience"))
        elif FILENAME_NEAR_RE.match(path.name):
            return None, "the file name looks like the _NAME-page.md convention but doesn't follow it"
    try:
        text = path.read_text(encoding="utf-8-sig")
    except (OSError, UnicodeDecodeError) as exc:
        return None, f"cannot read the page: {exc}"
    meta, problem = read_meta(text)
    if problem:
        return None, problem
    keys = [k for k in meta if str(k).lower() == "audiences"]
    if not keys:
        near = [str(k) for k in meta if audiences_like(str(k))]
        if near:
            return None, f"the frontmatter key {near[0]!r} isn't 'audiences', so the page counts as untagged"
        stray = stray_audience_line(text)
        if stray:
            return None, (f"the line {stray!r} near its top looks like a tag, but MkDocs reads "
                          "no audiences from the page")
    for key in keys:
        raw = meta[key]
        if isinstance(raw, str):
            found = [s.strip() for s in raw.split(",") if s.strip()]
        elif isinstance(raw, list) and all(s is not None for s in raw):
            found = [str(s).strip() for s in raw if str(s).strip()]
        elif raw is None or isinstance(raw, list):
            return None, ("'audiences:' has an empty value or entry; if it says '!name', YAML "
                          "read that as a tag: negation works in markers but not in frontmatter")
        else:
            return None, f"'audiences:' must be a list of names or a comma-separated string, not {raw!r}"
        if not found:
            return None, "'audiences:' names no audience"
        if any(n.startswith("!") for n in found):
            return None, "'audiences:' names a negated audience; negation works in markers but not in frontmatter"
        names.extend(found)
    return (names or None), None


def page_visible(audience: str, declared: list[str] | None, untagged: str = "public") -> bool:
    if audience == PRIVILEGED:
        return True
    if declared is None:
        return untagged == "public" and audience == "public"
    return audience in declared


def doc_pages(settings: dict) -> list[PurePosixPath]:
    """Every page MkDocs would build, found the way MkDocs finds them, so pages
    exclude_docs leaves out aren't checked."""
    from mkdocs.structure.files import InclusionLevel, get_files
    return sorted(
        PurePosixPath(f.src_uri) for f in get_files(settings["config"])
        if f.is_documentation_page() and f.inclusion != InclusionLevel.EXCLUDED
    )


def output_forms(page: PurePosixPath, use_directory_urls: bool) -> tuple[str, set[str]]:
    """Return (expected output file, every URL form that points at the page)."""
    stem = page.with_suffix("")
    if stem.name in ("index", "README"):
        directory = stem.parent.as_posix()
        out_file = posixpath.join(directory, "index.html") if directory != "." else "index.html"
        forms = {page.as_posix(), out_file}
        if directory != ".":
            forms.add(directory)
        return out_file, forms
    if use_directory_urls:
        out_file = f"{stem.as_posix()}/index.html"
        return out_file, {page.as_posix(), stem.as_posix(), out_file}
    out_file = f"{stem.as_posix()}.html"
    return out_file, {page.as_posix(), out_file}


class _Hrefs(HTMLParser):
    """Every href in a page, however it's quoted or cased, every other URL an
    element points at (src, srcset, poster, data), and every id a link's
    #fragment can point at (id, and name on <a>)."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.hrefs: list[str] = []
        self.refs: list[str] = []
        self.ids: set[str] = set()

    def handle_starttag(self, tag, attrs):
        for name, value in attrs:
            if not value:
                continue
            if name == "id" or (tag == "a" and name == "name"):
                self.ids.add(value)
            if name == "href":
                self.hrefs.append(value)
                self.refs.append(value)
            elif name in ("src", "poster", "data"):
                self.refs.append(value)
            elif name == "srcset":
                self.refs.extend(p.strip().split()[0] for p in value.split(",") if p.strip())


# url(...) and @import in a stylesheet.
CSS_REF_RE = re.compile(r"""url\(\s*(['"]?)(?P<url>[^'")]+)\1\s*\)|@import\s+(['"])(?P<imp>[^'"]+)\3""")
# Files every build gets, whether or not a page links to them.
ALWAYS_KEPT_TOP = {"CNAME", "robots.txt", "favicon.ico"}


def always_kept(rel: str) -> bool:
    parts = rel.split("/")
    return parts[0] == "assets" or rel in ALWAYS_KEPT_TOP or any(p.startswith(".") for p in parts)


def resolve_href(base_dir: str, href: str, site_url: str) -> str | None:
    href = href.strip().split("#", 1)[0].split("?", 1)[0]
    if site_url:
        for prefix in {site_url, site_url.rstrip("/")}:
            if prefix and href.startswith(prefix):
                href = "/" + href[len(prefix):].lstrip("/")
                break
    if not href or "://" in href or href.startswith(("mailto:", "tel:", "javascript:", "data:", "//")):
        return None
    href = unquote(href)
    if href.startswith("/"):
        path = urlsplit(site_url).path.strip("/") if site_url else ""
        href = href.lstrip("/")
        if path and (href == path or href.startswith(path + "/")):
            href = href[len(path):].lstrip("/")
        resolved = posixpath.normpath(href or ".")
    else:
        resolved = posixpath.normpath(posixpath.join(base_dir, href))
    return None if resolved in (".", "") else resolved


def link_target(base_dir: str, ref: str, site_url: str) -> tuple[str, str] | None:
    """Where a link within the site leads: (path in the build, fragment). The
    path is "" for the page itself, and starts with "../" when the link leaves
    the folder the site is served from. None for a full URL, or a mailto:,
    tel:, javascript: or data: link, which aren't checked."""
    parts = urlsplit(ref.strip())
    if parts.scheme or parts.netloc:
        return None
    path, frag = unquote(parts.path), unquote(parts.fragment)
    if path.startswith("/"):
        root = urlsplit(site_url).path.strip("/") if site_url else ""
        path = path.lstrip("/")
        if root:
            if not (path == root or path.startswith(root + "/")):
                return "../" + path, frag
            path = path[len(root):].lstrip("/")
        return posixpath.normpath(path or "."), frag
    if not path:
        return "", frag
    return posixpath.normpath(posixpath.join(base_dir, path)), frag


def broken_links(out: Path, links: dict[str, list[str]], ids: dict[str, set[str]],
                 sources: dict[str, str], skip: dict[str, str], site_url: str) -> list[str]:
    """Every link within the site, on every page of one build, that leads
    nowhere in that build: to no file or folder, or to an anchor no element on
    the target page has. A link to an excluded page (in skip) is reported as a
    leak instead."""
    found: list[str] = []
    for page, refs in links.items():
        where = sources.get(page, page)
        base = posixpath.dirname(page)
        for ref in refs:
            target = link_target(base, ref, site_url)
            if target is None:
                continue
            path, frag = target
            if path in skip:
                continue
            if path == ".." or path.startswith("../"):
                served = urlsplit(site_url).path if site_url else ""
                found.append(f"broken link: {where} -> {ref} (outside the site, which is "
                             f"served from {served or '/'})")
                continue
            if path == "":
                path = page
            elif path == "." or (out / path).is_dir():
                path = posixpath.join("" if path == "." else path, "index.html")
            if not (out / path).is_file():
                found.append(f"broken link: {where} -> {ref} (not in this build)")
            elif frag and path in ids and frag not in ids[path] and frag.lower() != "top":
                on = "this page" if path == page else sources.get(path, path)
                found.append(f"broken anchor: {where} -> {ref} (no anchor '{frag}' on {on} "
                             f"in this build)")
    return sorted(set(found))


def verify(out: Path, audience: str, pages, settings) -> tuple[list[str], list[str], int]:
    problems: list[str] = []
    excluded: dict[str, set[str]] = {}
    used: set[str] = set()
    links: dict[str, list[str]] = {}
    ids: dict[str, set[str]] = {}
    # The page each built HTML file comes from, to name it in messages.
    sources = {output_forms(page, settings["use_directory_urls"])[0]: page.as_posix() for page in pages}
    for page in pages:
        declared, _ = page_audiences(settings["docs_dir"] / page, settings["filename_convention"])
        if page_visible(audience, declared, settings["untagged"]):
            continue
        out_file, forms = output_forms(page, settings["use_directory_urls"])
        excluded[page.as_posix()] = forms
        if (out / out_file).exists():
            problems.append(f"excluded page was built: {page} -> {out_file}")

    all_forms = {form: page for page, forms in excluded.items() for form in forms}
    for path in sorted(p for p in out.rglob("*") if p.is_file()):
        rel = path.relative_to(out)
        if SOURCE_COPY_RE.search(path.name):
            problems.append(f"page source copied into the build: {rel}")
        if path.suffix.lower() in BINARY_SUFFIXES:
            continue
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        for lineno, line in enumerate(text.splitlines(), 1):
            if any(marker_like(m.group("word")) for m in COMMENT_WORD_RE.finditer(line)):
                problems.append(f"raw audience marker survived: {rel}:{lineno}")
        base = rel.parent.as_posix()
        base = "" if base == "." else base
        if path.suffix.lower() == ".css":
            for m in CSS_REF_RE.finditer(text):
                target = resolve_href(base, m.group("url") or m.group("imp"), settings["site_url"])
                if target:
                    used.add(target)
            continue
        if path.suffix.lower() not in (".html", ".htm"):
            continue
        parser = _Hrefs()
        try:
            parser.feed(text)
        except Exception:
            problems.append(f"page unreadable as HTML: {rel}")
            continue
        links[rel.as_posix()] = parser.refs
        ids[rel.as_posix()] = parser.ids
        for href in parser.hrefs:
            target = resolve_href(base, href, settings["site_url"])
            if target in all_forms:
                problems.append(f"link to excluded page {all_forms[target]}: {rel} -> {href}")
        for ref in parser.refs:
            target = resolve_href(base, ref, settings["site_url"])
            if target:
                used.add(target)

    # Outside internal, a file copied from docs/ that isn't a page has to be
    # used by the build; one only an excluded page used must not be there.
    if audience != PRIVILEGED:
        for path in sorted(p for p in out.rglob("*") if p.is_file()):
            rel = path.relative_to(out).as_posix()
            if not (settings["docs_dir"] / rel).is_file() or rel.lower().endswith(DOC_SUFFIXES):
                continue  # made by the build, or a page source (reported above)
            if always_kept(rel) or rel in used:
                continue
            problems.append(f"file no page in this build links to: {rel}")

    index = out / "search" / "search_index.json"
    if index.exists():
        try:
            docs = json.loads(index.read_text(encoding="utf-8")).get("docs", [])
        except (OSError, ValueError):
            docs = []
            problems.append("search index unreadable: search/search_index.json")
        listed = set()
        for doc in docs:
            loc = posixpath.normpath(str(doc.get("location", "")).split("#")[0] or ".")
            if loc in all_forms and loc not in listed:
                listed.add(loc)
                problems.append(f"search index lists excluded page {all_forms[loc]}")

    broken = broken_links(out, links, ids, sources, all_forms, settings["site_url"])
    return sorted(set(problems)), broken, len(excluded)


def build(audience: str, out: Path, config_file: Path, builder: str) -> bool:
    exe = Path(sys.prefix) / "bin" / builder
    exe = str(exe) if exe.exists() else shutil.which(builder)
    if not exe:
        if builder == "mkdocs":
            log("error: 'mkdocs' not found. Install the project first: pip install -r requirements.txt")
        else:
            log(f"error: '{builder}' not found. Install it into the project's environment first: "
                f"uv pip install {builder}  (without uv: .venv/bin/pip install {builder})")
        sys.exit(2)
    env = dict(os.environ, MKDOCS_AUDIENCE=audience, NO_MKDOCS_2_WARNING="true")
    cmd = [exe, "build", "--strict", "--clean", "--config-file", str(config_file), "--site-dir", str(out)]
    result = subprocess.run(cmd, cwd=config_file.parent, env=env, text=True,
                            stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    if result.returncode != 0:
        log(f"{audience}: BUILD FAILED (exit {result.returncode}); last lines of output:")
        for line in result.stdout.strip().splitlines()[-15:]:
            print(f"    {line}")
        return False
    return True


def main() -> int:
    parser = argparse.ArgumentParser(description="Build every audience; verify nothing leaked and no link is broken.")
    parser.add_argument("--config", default=None, help="path to mkdocs.yml (default: project root)")
    parser.add_argument("--dist", default=None, help="output root (default: <project>/dist)")
    parser.add_argument("--builder", default="mkdocs", help="build command: mkdocs (default) or properdocs")
    parser.add_argument("--no-build", action="store_true", help="verify existing <dist>/<audience>/ only")
    args = parser.parse_args()

    config_file = Path(args.config).resolve() if args.config else ROOT / "mkdocs.yml"
    dist = Path(args.dist).resolve() if args.dist else ROOT / "dist"
    settings = load_settings(config_file)
    pages = doc_pages(settings)
    if not pages:
        log(f"error: no Markdown pages found under {settings['docs_dir']}")
        return 2

    unreadable = []
    for page in pages:
        names, problem = page_audiences(settings["docs_dir"] / page, settings["filename_convention"])
        if problem:
            unreadable.append(f"{page}: {problem}")
        elif names is None and settings["untagged"] == "error":
            unreadable.append(f"{page}: names no audiences; add audiences: to its frontmatter, "
                              "or set untagged: public in mkdocs.yml")
    if unreadable:
        log("FAILED before building: these pages' tagging can't be trusted, so a build could")
        log("publish them to the wrong audience. Fix them first:")
        for line in unreadable:
            print(f"    {line}")
        return 1

    failed: list[str] = []
    for audience in settings["audiences"]:
        out = dist / audience
        if args.no_build:
            if not out.is_dir():
                log(f"{audience}: FAIL; {out} does not exist (build it first, or drop --no-build)")
                failed.append(audience)
                continue
            how = "existing output"
        else:
            if not build(audience, out, config_file, args.builder):
                failed.append(audience)
                continue
            how = "built"
        problems, broken, n_excluded = verify(out, audience, pages, settings)
        excluded_note = f" ({n_excluded} excluded)" if n_excluded else ""
        checked = f"{len(pages)} page{'' if len(pages) == 1 else 's'} checked"
        found = []
        if problems:
            found.append(f"LEAK ({len(problems)} problem{'' if len(problems) == 1 else 's'})")
        if broken:
            found.append(f"BROKEN LINKS ({len(broken)})")
        if found:
            log(f"{audience}: {how}; {checked}{excluded_note}; {'; '.join(found)}")
            listed = problems + broken
            for problem in listed[:20]:
                print(f"    {problem}")
            if len(listed) > 20:
                print(f"    ... and {len(listed) - 20} more")
            failed.append(audience)
        else:
            log(f"{audience}: {how}; {checked}{excluded_note}; clean")

    if failed:
        log(f"FAILED: {', '.join(failed)}. Do not publish these builds.")
        return 1
    log("all audiences clean")
    return 0


if __name__ == "__main__":
    sys.exit(main())
BD_CHECKER_EOF
    chmod +x bin/check-leaks.py
}

scaffold_serve() {
    write_owned bin/serve <<'BD_SERVE_EOF'
#!/usr/bin/env bash
# Preview this project in your browser.
#
#   bin/serve              the internal build, live: it rebuilds as you save
#   bin/serve public       any audience declared in mkdocs.yml, live
#   bin/serve all          every build at one address, as each audience will
#                          get it: checked first, and not live
#
# Serves on 127.0.0.1 only, so other machines can't reach it. Uses port 8000,
# or the next free port up to 8020. Ctrl-C stops it.
#
# A live preview doesn't stop on warnings: a link to a page or file that
# doesn't exist prints a WARNING line here, and the page still updates. Other
# broken links print less: a missing anchor, an absolute or folder-style link
# to nothing, or a link written in HTML prints an INFO line or nothing.
# bin/check-leaks.py and CI fail on every broken link, so run it before you
# publish.

set -euo pipefail
cd "$(dirname "$0")/.."

usage() {
    echo "usage: bin/serve [audience | all]    (default: internal)"
}

case "${1:-}" in
    -h|--help) usage; exit 0 ;;
esac
if [ $# -gt 1 ]; then
    usage >&2
    exit 2
fi
audience="${1:-internal}"

if [ ! -x .venv/bin/mkdocs ]; then
    cat >&2 <<'EOF'
[serve] error: .venv/bin/mkdocs not found, so the project isn't installed here.
  Install it from this folder, then run bin/serve again:
    python3 -m venv .venv
    .venv/bin/pip install -r requirements.txt
  With uv instead: uv venv, then uv pip install -r requirements.txt
EOF
    exit 1
fi

# The first free port from 8000 to 8020; empty if none is free. It binds the
# way MkDocs's server does (SO_REUSEADDR), so a port still settling after a
# preview that just stopped counts as free, and a running server doesn't.
free_port() {
    .venv/bin/python - <<'PY'
import socket
for port in range(8000, 8021):
    with socket.socket() as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            s.bind(("127.0.0.1", port))
        except OSError:
            continue
        print(port)
        break
PY
}

pick_port() {
    port=$(free_port)
    if [ -z "$port" ]; then
        echo "[serve] error: ports 8000 to 8020 are all in use. Stop another preview (Ctrl-C in its terminal) and try again." >&2
        exit 1
    fi
    if [ "$port" != 8000 ]; then
        echo "[serve] port 8000 is in use (another preview still running?); using $port"
    fi
}

if [ "$audience" = all ]; then
    if [ ! -f bin/check-leaks.py ]; then
        echo "[serve] error: 'bin/serve all' needs bin/check-leaks.py, which this project doesn't have (WANT_LEAK_CHECK=false)." >&2
        exit 1
    fi
    echo "[serve] building and checking every audience first: bin/check-leaks.py"
    if ! .venv/bin/python bin/check-leaks.py; then
        echo "[serve] error: the leak check failed (see above), so nothing was served. To look at one build meanwhile: bin/serve internal" >&2
        exit 1
    fi
    pick_port
    exec .venv/bin/python - "$port" <<'PY'
# Serve the builds bin/check-leaks.py just made in dist/, with a page at /
# that lists them. Only the audiences mkdocs.yml declares are served: a
# folder an audience you have since removed left in dist/ wasn't checked.
import html
import http.server
import io
import posixpath
import sys
import threading
import urllib.parse
import webbrowser
from pathlib import Path

import yaml

port = int(sys.argv[1])
root = Path("dist")


# A SafeLoader that ignores tags it doesn't know (!ENV, !!python/name, ...).
class Loader(yaml.SafeLoader):
    pass


Loader.add_multi_constructor("", lambda loader, suffix, node: None)
cfg = yaml.load(Path("mkdocs.yml").read_text(encoding="utf-8"), Loader=Loader) or {}
title = str(cfg.get("site_name") or "Documentation")
plugins = cfg.get("plugins") or []
if isinstance(plugins, dict):
    plugins = [{name: opts} for name, opts in plugins.items()]
declared = ["internal", "public"]
for entry in plugins:
    if isinstance(entry, dict) and "audience" in entry:
        declared = list((entry["audience"] or {}).get("audiences") or declared)
builds = [str(n) for n in declared if (root / str(n) / "index.html").is_file()]

what = {
    "internal": "Every page and block, each marked block noted",
    "partner": "Pages and blocks tagged partner",
    "beta": "Pages and blocks tagged beta",
    "public": "Pages and blocks tagged public",
}
rows = "\n".join(
    f'<li class="b-{html.escape(n)}"><a href="{html.escape(n)}/">{html.escape(n.capitalize())}</a>'
    f'<span>{html.escape(what.get(n, "Pages and blocks tagged " + n))}</span></li>'
    for n in builds
)
fonts = ""
for n in builds:
    if (root / n / "assets/fonts/charter_regular.woff2").is_file():
        fonts = "".join(
            f'@font-face{{font-family:"Charter";src:url("{html.escape(n)}/assets/fonts/charter_{f}.woff2") format("woff2");'
            f"font-weight:{w};font-style:{s};ascent-override:98%;descent-override:23.6%;line-gap-override:0%}}"
            for f, w, s in (("regular", 400, "normal"), ("italic", 400, "italic"), ("bold", 700, "normal"))
        )
        break
page = f'''<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}: every build</title>
<style>
{fonts}
:root{{--paper:#fff;--ink:#1d1e20;--ink-3:#737881;--rule:#e3e5e8;--link:#007a6c;--hover:#ff6e42;--internal:#7b3fa8;--partner:#1f62ad;--beta:#965a08;--public:#22724a;--other:#4a4e55;color-scheme:light dark}}
@media (prefers-color-scheme:dark){{:root{{--paper:#17181b;--ink:#e6e7e9;--ink-3:#888d95;--rule:#2d2f34;--link:#00ccb8;--internal:#c59cea;--partner:#8cb8f2;--beta:#e5b160;--public:#80c89c;--other:#b6b9bf}}}}
body{{margin:0;background:var(--paper);color:var(--ink);font:18px/1.6 "Charter","Bitstream Charter",Palatino,"Palatino Linotype","Noto Serif","Liberation Serif",Georgia,serif}}
main{{max-width:34rem;margin:12vh auto;padding:0 1.25rem}}
h1{{font-weight:400;font-size:2.1rem;line-height:1.15;letter-spacing:-.01em;margin:0 0 .35rem}}
p{{margin:0 0 2.2rem;color:var(--ink-3);font-style:italic}}
ul{{list-style:none;margin:0;padding:0;border-top:1px solid var(--rule)}}
li{{display:flex;gap:1.2rem;align-items:baseline;padding:.8rem 0 .8rem 1rem;border-bottom:1px solid var(--rule);border-left:2px solid var(--other)}}
li.b-internal{{border-left-color:var(--internal)}}li.b-partner{{border-left-color:var(--partner)}}li.b-beta{{border-left-color:var(--beta)}}li.b-public{{border-left-color:var(--public)}}
a{{color:var(--link);font-weight:700;min-width:5.5rem;text-decoration:none}}
a:hover,a:focus{{color:var(--hover)}}
span{{color:var(--ink-3)}}
@media (max-width:30rem){{li{{flex-direction:column;gap:0}}}}
</style>
<main>
<h1>{html.escape(title)}</h1>
<p>Every build, as its audience will get it.</p>
<ul>
{rows}
</ul>
</main>
'''
body = page.encode("utf-8")


class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(root), **kwargs)

    def log_message(self, *args):
        pass

    def send_head(self):
        path = posixpath.normpath(urllib.parse.unquote(urllib.parse.urlsplit(self.path).path))
        if path in ("/", "/index.html"):
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            return io.BytesIO(body)
        if path.lstrip("/").split("/", 1)[0] not in builds:
            self.send_error(404, "Not one of this project's checked builds")
            return None
        return super().send_head()


url = f"http://127.0.0.1:{port}/"
try:
    server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
except OSError as exc:
    sys.exit(f"[serve] error: can't serve on 127.0.0.1:{port} ({exc.strerror}). Run bin/serve all again.")
with server:
    print(f"[serve] {', '.join(builds)} on {url}; your browser opens to a page listing them. Ctrl-C stops it.", flush=True)
    threading.Timer(0.3, webbrowser.open, [url]).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[serve] stopped.")
PY
fi

pick_port
echo "[serve] $audience preview on 127.0.0.1:$port; your browser opens once it's built. Ctrl-C stops it."

# NO_MKDOCS_2_WARNING hides Material's notice about MkDocs 2.0 (see the guide).
export MKDOCS_AUDIENCE="$audience" NO_MKDOCS_2_WARNING=true
exec .venv/bin/mkdocs serve --open --no-strict --dev-addr "127.0.0.1:$port"
BD_SERVE_EOF
    chmod +x bin/serve
}

scaffold_ci() {
    local check_step
    if $WANT_LEAK_CHECK; then
        check_step="      - name: Build every audience and verify nothing leaked
        run: python bin/check-leaks.py"
    else
        check_step="      - name: Build every audience (strict)
        env:
          NO_MKDOCS_2_WARNING: \"true\"
        run: |
          for a in $(audience_list ' '); do
            MKDOCS_AUDIENCE=\$a mkdocs build --strict --site-dir dist/\$a
          done"
    fi
    write_owned .github/workflows/build.yml <<EOF
name: build docs

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements.txt
${check_step}
      - name: Upload the public build
        uses: actions/upload-artifact@v7
        with:
          name: public
          path: dist/public
EOF
}

# Check every audience, then start the preview, or say how to start it.
finish() {
    if $WANT_LEAK_CHECK; then
        log "building and checking every audience: bin/check-leaks.py"
        if ! bin/check-leaks.py; then
            die "the leak check failed (see above), so the preview was not started.
  Fix what it lists and run this script again. To look at the site meanwhile:
    $(cd_line) && bin/serve"
        fi
    fi
    if ! $WANT_SERVE; then
        print_done "Done. To preview:"
        return 0
    fi
    if [ ! -t 0 ] || [ ! -t 1 ]; then
        note "no terminal (input or output is redirected), so the preview was not started"
        print_done "Done. To preview:"
        return 0
    fi
    print_done "Done. Starting the internal preview. To start it again later:"
    exec bin/serve internal
}

# The cd command for the project, written so that it can be pasted into a shell.
cd_line() {
    case "$SHOWN_PATH" in
        *[!A-Za-z0-9._/~+-]*) printf 'cd %q' "$PROJECT_PATH" ;;
        *) printf 'cd %s' "$SHOWN_PATH" ;;
    esac
}

print_done() {
    local others=""
    if $WANT_PARTNER && $WANT_BETA; then
        others=" (or partner, or beta)"
    elif $WANT_PARTNER; then
        others=" (or partner)"
    elif $WANT_BETA; then
        others=" (or beta)"
    fi
    echo
    echo "$1"
    echo
    echo "  $(cd_line)"
    echo "  bin/serve              the internal build, which shows everything"
    echo "  bin/serve public       the public build${others}"
    if $WANT_LEAK_CHECK; then
        echo "  bin/serve all          every build at one address, as each audience gets it"
    fi
    echo
    if $WANT_LEAK_CHECK; then
        echo "Before you publish, run bin/check-leaks.py. It rebuilds dist/<audience>/ and"
        echo "must end with \"all audiences clean\"."
    else
        echo "To build the public site into dist/public/:"
        echo "  MKDOCS_AUDIENCE=public .venv/bin/mkdocs build --site-dir dist/public"
    fi
    echo
}

main "$@"
exit
}

GitLab Hosting with a GitHub Mirror

  • For: anyone who wants their repository’s home on GitLab, with a copy on GitHub that keeps itself up to date.
  • Before you start: a git repository, on your computer or on GitHub, and git installed (on Debian or Ubuntu, sudo apt install git).
  • Reading time: about 14 minutes.
  • You end with: the repository hosted on GitLab, every push copied to GitHub within minutes, the GitHub copy set up to behave as a mirror, and a plan for when either side drops you.

Throughout, GITLAB_USER and GITHUB_USER stand for your usernames on each site, and REPO for the repository’s name. Put yours in their place wherever they appear, commands included.

Why host on GitLab and mirror to GitHub

The repository lives on GitLab: that’s where you push, review and run CI. GitLab copies every branch, tag and commit to GitHub by itself, so GitHub always has a current copy. What that buys you:

  • Two independent copies, run by two companies. If one suspends your account, takes a repository down over a complaint, or goes offline, the other still has every branch and tag, and so does your own clone. Moving to the survivor takes minutes, because nothing has to be rebuilt.
  • GitHub’s reach, without GitHub as the home. Most developers already have a GitHub account, and many search there first. The mirror keeps the project findable, starrable and forkable on GitHub, while the work happens on GitLab.
  • A home you can take with you. GitLab’s core is open source under the MIT licence; only the paid tiers’ extra code isn’t. If gitlab.com stops suiting you, the same software can run on a server of your own. It’s a heavy server to run, but the option is there. GitHub’s self-hosted product is closed source and sold only as a paid licence.
  • Mirroring is built in and free. Push mirroring is part of GitLab’s free tier. You set it up once, and every push reaches GitHub within five minutes, with no script, server or scheduled job of yours involved.
  • CI and a website on the free tier. GitLab CI/CD and GitLab Pages come with a free account, with 400 minutes a month of build time on GitLab’s machines.
  • Less lock-in. A project that lives on two forges can’t quietly come to depend on one forge’s special features, which keeps the next move cheap too.

What it costs

None of these is hidden, but some catch people out. Weigh them before you start.

  • Contributors still arrive on GitHub. Issues and pull requests can be opened on the mirror, and you have to bring them over by hand (section 8). Anything merged on GitHub itself is overwritten at the next mirror update.
  • Only git data is mirrored: branches, tags and commits. Issues, merge requests, wikis, releases and settings stay on the site where they were made.
  • GitLab holds a GitHub token that can write to the mirror. You scope it to the one repository, give it an expiry, and renew it before it runs out.
  • GitHub lags GitLab by up to five minutes.
  • Workflows under .github/workflows/ run on GitHub too, on every mirrored push. Anything that publishes, such as a package, a website or a release, must run on only one of the two.
  • GitLab’s free CI may ask for a phone number or a card before it runs your jobs. GitLab decides from a risk score, so you can’t know in advance.
  • GitHub refuses files over 100 MB, so a repository that holds one can’t be mirrored there unless the file is stored with Git LFS.
  • Both are US companies. Two copies protect you from one company’s decision, not from a legal order that reaches both. If that matters to you, keep a third copy in another jurisdiction, such as Codeberg in Germany, or on a server of your own.

1. Accounts and SSH keys

  1. Make an account on gitlab.com, and on github.com if you don’t have one.

  2. Check for an SSH key:

    ls ~/.ssh/id_ed25519.pub
    

    It prints the file’s path if you have one. No such file or directory means you don’t, so make one:

    ssh-keygen -t ed25519 -C "you@example.org"
    

    It asks where to save the key (press Enter for the default) and for a passphrase, twice. Then it prints the key’s fingerprint and a box of symbols, the key’s randomart. The box is normal.

  3. Add the public key to both sites: on GitLab, avatar → Edit profile → SSH Keys → Add new key; on GitHub, Settings → SSH and GPG keys → New SSH key. Paste the whole of ~/.ssh/id_ed25519.pub; cat ~/.ssh/id_ed25519.pub prints it, one line starting ssh-ed25519.

  4. Check both:

    ssh -T git@gitlab.com
    ssh -T git@github.com
    

    The first time for each, ssh asks whether to trust the host. Answer yes only if the ED25519 fingerprint it shows matches: GitLab’s is SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8, and GitHub’s is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU. Then GitLab prints Welcome to GitLab, @GITLAB_USER!, and GitHub prints Hi GITHUB_USER! You've successfully authenticated, but GitHub does not provide shell access. That second message means success, although ssh exits with an error status after it.

2. The GitHub repository

If the repository is already on GitHub, it becomes the mirror, with its history as it is: there’s nothing to do here.

Otherwise, create an empty one: on GitHub, New repository, name REPO, the same visibility you’ll give it on GitLab, with no README, .gitignore or licence → Create repository. The mirror fills it.

3. Put the repository on GitLab

Pick the case that fits yours.

3.1 From your computer

If the repository isn’t on any server yet, first create an empty GitLab project: Create new (+) → New project/repository → Create blank project. Name it REPO, choose its visibility, untick “Initialize repository with a README” → Create project. An initialised project would reject your first push.

Then, in the repository’s folder:

git remote add origin git@gitlab.com:GITLAB_USER/REPO.git
git push -u origin --all
git push origin --tags

The first prints nothing; if it says remote origin already exists, use git remote set-url origin with the same address instead. The second prints a * [new branch] line per branch, and a branch '…' set up to track 'origin/…'. line for each. The third prints a * [new tag] line per tag, or Everything up-to-date if you have none.

3.2 From GitHub, code only

This copies every branch and tag, and nothing else. Create the empty GitLab project as in 3.1, then:

git clone --bare git@github.com:GITHUB_USER/REPO.git
git -C REPO.git push --all git@gitlab.com:GITLAB_USER/REPO.git
git -C REPO.git push --tags git@gitlab.com:GITLAB_USER/REPO.git
rm -rf REPO.git

The clone prints Cloning into bare repository 'REPO.git'... and some progress lines. The pushes print a * [new branch] line per branch, then a * [new tag] line per tag. rm prints nothing.

Use --bare, not --mirror. A mirror clone also copies GitHub’s refs/pull/ references, which then clutter GitLab, and which GitHub refuses with deny updating a hidden ref when the mirror pushes them back.

3.3 From GitHub, with issues and pull requests

GitLab’s importer brings the repository along with its issues, pull requests (as merge requests), comments, labels, milestones, wiki and release notes. In GitLab: Create new (+) → New project/repository → Import project → GitHub.

Sign in with GitHub if GitLab offers it. Otherwise it asks for a GitHub token: a classic personal access token with the repo scope, plus read:org if you use Git LFS or want collaborators imported. That token can reach all your repositories, so delete it on GitHub once the import has finished.

The import also brings branches from forks that have open pull requests. Delete any you don’t want copied to GitHub before you set up the mirror in section 5.

4. Point your clone at GitLab

In your working clone:

git remote set-url origin git@gitlab.com:GITLAB_USER/REPO.git
git remote add github git@github.com:GITHUB_USER/REPO.git
git remote set-url --push github no-push
git fetch origin
git remote -v

If you started from your computer (3.1), origin is GitLab already, so skip the first line. The first three lines print nothing. git fetch prints nothing when GitLab has nothing your clone lacks, or a From gitlab.com:GITLAB_USER/REPO line followed by what was new. The last prints:

github	git@github.com:GITHUB_USER/REPO.git (fetch)
github	no-push (push)
origin	git@gitlab.com:GITLAB_USER/REPO.git (fetch)
origin	git@gitlab.com:GITLAB_USER/REPO.git (push)

origin is GitLab now, so git push goes there. The github remote is for fetching pull requests (section 8). Its push address is a dud on purpose, because anything you push to the mirror directly is overwritten at its next update. If you try, git says fatal: 'no-push' does not appear to be a git repository.

5. Set up the mirror

GitLab pushes to GitHub over HTTPS, with a token. This is GitLab’s documented way to mirror to GitHub, and the only one that also copies Git LFS files. Section 10 covers an SSH key instead, and pushing to both from your own computer.

  1. On GitHub, make the token: Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.

    • Token name: something you’ll recognise, such as gitlab mirror REPO.
    • Expiration: a year at most. GitHub lets a personal account’s token never expire, but that leaves a permanent key to your repository on another company’s servers.
    • Repository access: Only select repositories, then GITHUB_USER/REPO.
    • Permissions: Contents, Read and write. If the repository has a .github/workflows/ folder, also Workflows, Read and write: without it, GitHub refuses any push that changes those files. GitHub adds Metadata, Read-only, by itself.
    • Generate token, and copy it: GitHub shows it only once.

    Put the expiry date in your calendar.

  2. On GitLab, in the project: Settings → Repository → Mirroring repositories → Add new.

    • Git repository URL: https://github.com/GITHUB_USER/REPO.git
    • Mirror direction: Push
    • Authentication method: Username and Password, with GITHUB_USER as the username and the token as the password.
    • Leave Keep divergent refs off, so GitLab wins whenever the two differ. That’s what makes GitHub a mirror.
    • Optionally, tick Mirror only protected branches, if you want only those (usually just the default branch) on GitHub. Updates then arrive within a minute rather than five.
    • Select Mirror repository.
  3. On the new row under Mirrored repositories, select Update now. Shortly after, GitHub shows your branches and tags, and the row shows its last successful update. Update now works at most once every five minutes. From here on, every push to GitLab reaches GitHub by itself, within five minutes.

6. Make the GitHub copy behave like a mirror

  1. Description: on the repository’s GitHub page, use the gear beside About to set something like “Mirror of https://gitlab.com/GITLAB_USER/REPO. Issues and merge requests there.”
  2. Settings → General → Features: untick Issues and Wikis, so nobody is left talking to a copy. Pull requests can still arrive; section 8 handles them.
  3. Settings → Actions → General: if anything in .github/workflows/ publishes or deploys (a package, a website, a release), make sure it runs on only one host. Either choose Disable actions here and do that work in GitLab CI, or keep it here and leave it out of GitLab CI.
  4. Leave GitHub’s default branch without rules that block direct pushes, such as requiring pull requests. The mirror pushes directly, so such rules make every update fail.
  5. If your GitHub account has “Block command line pushes that expose my email” turned on (Settings → Emails), keep your private email address out of your commits, or the mirror’s updates can fail.
  6. Say where the project lives in the first lines of the README, so anyone who lands on GitHub knows where to file issues.

7. Everyday work

Nothing changes: commit and push as usual.

git push

It prints one To gitlab.com:GITLAB_USER/REPO.git block. When you push a new branch, GitLab adds remote: lines with a link for creating a merge request; that’s an offer, not an error. GitHub follows within five minutes.

To check the mirror, look at Settings → Repository → Mirroring repositories on GitLab. Its row shows the last successful update, or the error that stopped it (section 12).

Now and then, export the GitLab project (Settings → General → Advanced → Export project) and keep the file. It holds the issues, merge requests, wiki and releases, which the mirror doesn’t. GitLab deletes the export after 24 hours, and warns that exports aren’t a dependable backup, so treat yours as better than nothing rather than as a backup.

8. Pull requests that arrive on GitHub

Never merge on GitHub: the next mirror update overwrites GitHub’s branch with GitLab’s, and the merge disappears from GitHub. Bring the pull request to GitLab instead, with its number in place of 7:

git fetch github pull/7/head:pr-7
git push origin pr-7

The fetch prints From github.com:GITHUB_USER/REPO and * [new ref] refs/pull/7/head -> pr-7. The push prints * [new branch] pr-7 -> pr-7, and GitLab adds a link for creating a merge request from the branch. Open it, then review and merge on GitLab as usual.

When the merge reaches GitHub through the mirror, GitHub marks the pull request merged if its commits arrived unchanged. If you squashed or rebased them, close it by hand, with a link to the merge request.

9. CI and a website

GitLab runs CI from a .gitlab-ci.yml file at the top of the repository, and GitHub runs .github/workflows/. A mirrored repository carries both files, and each site ignores the other’s.

On GitLab’s free tier, jobs run on GitLab’s machines, within 400 compute minutes a month. If a pipeline stops with “Identity verification is required in order to run CI jobs”, select Verify my account in the banner, then run the pipeline again. GitLab may ask for a phone number or a card. Its alternative is a runner on a machine of your own, which builds only while that machine is on.

GitLab Pages publishes a website from a job with pages: true that leaves the finished site in a folder named public. A starting point:

pages:
  image: debian:stable-slim          # an image that has your site generator
  script:
    - ./build-site --output public   # your generator's build command
  pages: true
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Replace the image and the script line with your generator’s. Deploy → Pages then shows the address. New projects get a unique address of their own by default, with the site at its root; to use the older GITLAB_USER.gitlab.io/REPO/ form instead, clear Use unique domain on that page.

Keep the build itself in a script in the repository, and have each CI file call it. Then GitLab, GitHub and your own computer build the same way, and moving CI later is a small change.

10. Other ways to keep GitHub in step

10.1 An SSH key instead of a token

Pick this if you’d rather not renew a token, and the repository doesn’t use Git LFS: GitLab copies LFS files only over HTTPS. The key works for this one repository only, and doesn’t expire.

  1. In GitLab’s Add new mirror form, set Git repository URL to ssh://github.com/GITHUB_USER/REPO.git (in this form, not git@github.com:…) and Mirror direction to Push, then select Detect host keys. Check that the ED25519 fingerprint it shows is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU. Set Authentication method to SSH public key and Username to git, then select Mirror repository.
  2. On the new row, select Copy SSH public key.
  3. On GitHub, in the repository: Settings → Deploy keys → Add deploy key. Paste the key, tick Allow write access → Add key.
  4. Select Update now on GitLab, and check GitHub as in section 5.

If GitHub ever changes its host key, the mirror fails with a host key error. GitLab’s fix is to delete the mirror and add it again.

10.2 Push to both from your computer

Pick this if you work alone, always push from one clone, and never merge in GitLab’s web interface. No token or key is stored on either site. It misses anything that happens on GitLab’s servers, such as a merge request merged in the browser, and it covers only the clones you set it up in. In the clone:

git remote set-url --add --push origin git@gitlab.com:GITLAB_USER/REPO.git
git remote set-url --add --push origin git@github.com:GITHUB_USER/REPO.git

Both print nothing. From then on, each git push prints a To … block for GitLab, then one for GitHub. The first line is needed because once any push address is set, git pushes only to push addresses. If GitLab can’t be reached at all, git stops there without pushing to GitHub; a push GitLab refuses doesn’t stop it. Don’t combine this with GitLab’s mirror.

11. When one side drops you

11.1 GitHub drops the mirror

Your work carries on: GitLab is the home. Delete the mirror’s row on GitLab (Settings → Repository → Mirroring repositories), and update the README if you set up a copy somewhere else.

11.2 GitLab drops you

Your clone and the GitHub mirror have every commit up to the last mirror update, and your clone has anything you pushed in the five minutes before. What lived only on GitLab, such as issues, merge requests, the wiki and CI settings, is gone unless you exported it (section 7).

To make GitHub the home:

git remote set-url origin git@github.com:GITHUB_USER/REPO.git
git remote remove github
git push --all origin
git push --tags origin

The first two print nothing. Each push prints Everything up-to-date if the mirror was current, or a line for each branch or tag it sends. Then, on GitHub, turn back on what you turned off in section 6, such as Issues, Wikis and Actions, and update the description and the README.

GitHub has no built-in push mirroring, so for a new copy elsewhere, use 10.2 with GitHub’s address first.

12. When the mirror fails

The mirror’s row on GitLab shows the error. The usual ones:

  • Authentication failed: the token has expired, or was deleted. Make a new one as in section 5, then delete the mirror’s row and add it again with the new token.
  • refusing to allow a Personal Access Token to create or update workflow: the token lacks the Workflows permission. Make a new one that has it.
  • 13:Received RST_STREAM with error code 2: GitLab’s docs give two causes: GitHub blocked a push that exposes your email address (section 6, item 5), or the repository holds a file over GitHub’s 100 MB limit.
  • LFS objects are missing: the mirror uses SSH, which doesn’t carry LFS files. Use the token method in section 5 instead.
  • A rejection on the default branch: a protection rule on GitHub blocks direct pushes (section 6, item 4).

Known gaps

  • The steps follow GitLab’s and GitHub’s documentation as of October 2026, and both sites move their menus around. If a label differs, look for the same words nearby.
  • The SSH key method (10.1) isn’t in GitLab’s GitHub-specific instructions, which show only the token. It combines GitLab’s general SSH mirror support with GitHub’s deploy keys, and hasn’t been tested end to end for this doc.
  • GitLab’s docs don’t say whether deleting a branch on GitLab deletes it on GitHub. Check the first time you delete one.
  • The git commands and their output were tested against local repositories standing in for GitLab and GitHub, not against the live sites. Over the network, git adds progress lines, and each site may add remote: lines.

Pubky Domain with Mirrors

  • For: anyone with a Start9 box and a static site on GitLab, mirrored to GitHub, who wants an address for it that no registrar can take, with a copy for every kind of browser.
  • Before you start: the site on GitLab Pages, mirrored to GitHub (gitlab-hosting-github-mirror covers that), a Start9 box on StartOS 0.4, and a domain you’ve bought.
  • Reading time: about 15 minutes.
  • You end with: the site at four addresses (your domain, a GitHub copy, an onion copy on your Start9, and a Pubky domain for Pubky’s browser), the words to tell readers which opens where, and a plan for when any one of them goes.

Throughout, example.org stands for your domain, GITLAB_USER and GITHUB_USER for your usernames, REPO for the repository, KEY for your Pubky domain (your public key), ONION for your onion address, and VPS_USER and VPS_IP for the account and address of a small server. Put yours in their place wherever they appear, commands included.

The four addresses

A static site is a folder of files, so the same build can be served from anywhere. Each address below covers a different way of losing the others.

AddressOpens inServed byCovers you when
https://www.example.org/any browserGitLab Pagesthe normal case
https://GITHUB_USER.github.io/REPO/any browserGitHub PagesGitLab drops you
http://ONION.onion/Tor Browseryour Start9 boxthe domain is seized, or both hosts drop you
http://KEY./Pubky’s browser, or a browser set to a PKDNS resolvera small VPS now, a Pubky homeserver laterthe domain is seized, for readers without Tor

The GitHub address exists only if the repository’s GitHub workflows build a Pages site. If yours don’t, the GitHub copy is the code alone, and the other three addresses carry the site.

A Pubky domain is a name made of your own Ed25519 public key, 52 characters long; Pubky’s documentation calls these Public Key Domains. You sign its records and publish them to BitTorrent’s Mainline DHT, so no registrar or court order against a registrar can take it away. It’s Pubky’s system, from Synonym. Its costs are on the reader’s side: an ordinary browser can’t resolve it, and today it’s served over plain HTTP. That’s why it sits beside the other addresses instead of replacing them.

Build the site to work from the root of an address, with relative links wherever your generator allows. Every address here serves it at the root except GitHub’s project address, which serves it under /REPO/.

1. Your domain on GitLab

The mirror is set up as in gitlab-hosting-github-mirror. To put your domain in front of GitLab Pages:

  1. Deploy → Pages → New Domain: domain www.example.org, with “Automatic certificate management using Let’s Encrypt” off for now → create. Do the same for example.org. GitLab shows a verification record for each.

  2. At your DNS host, add these, with a TTL of 300 seconds or the lowest it allows, so a later move reaches readers in minutes:

    www                                  CNAME  GITLAB_USER.gitlab.io.
    @                                    A      35.185.44.232
    _gitlab-pages-verification-code.www  TXT    gitlab-pages-verification-code=…
    _gitlab-pages-verification-code      TXT    gitlab-pages-verification-code=…
    

    Each TXT value is the one GitLab showed for that domain. Give the apex no AAAA record: GitLab’s Let’s Encrypt notes ask for a single A record and no AAAA record. Add no wildcard records.

  3. Once the TTL has passed, check:

    dig +short www.example.org
    

    It prints GITLAB_USER.gitlab.io. and then 35.185.44.232. No output means the records haven’t propagated yet, which can take up to 24 hours. If dig isn’t installed, sudo apt install bind9-dnsutils installs it and ends with a Setting up bind9-dnsutils line.

  4. In Deploy → Pages → Domains & settings, open each domain with Edit, and select Retry verification until it shows as verified. Keep the TXT records: GitLab re-checks them.

  5. Turn on “Automatic certificate management using Let’s Encrypt” for both domains. GitLab says the certificate can take up to an hour.

  6. On Deploy → Pages, set Primary domain to www.example.org, so the apex and the gitlab.io name redirect to it.

  7. curl -sI https://www.example.org/ | head -1 prints a status line ending in 200.

  8. Verify the domain on your GitHub account too, so no other GitHub repository can claim it if you ever move it there (6.1): profile Settings → Pages → Add a domain → example.org. Add the TXT record GitHub shows, named _github-pages-challenge-GITHUB_USER, select Verify, and keep the record. dig +short TXT _github-pages-challenge-GITHUB_USER.example.org prints its value in double quotes.

2. The onion copy on your Start9

This assumes StartOS 0.4, where Tor is a service you install.

  1. Marketplace → NextExplorer → Install. Run its Set Admin Password action, store the email and password it shows in your password manager, start it, open its Web UI and sign in.
  2. Under Locations → Files, create a folder named site.
  3. Get the built site: on GitLab, Build → Jobs → the latest pages job → Download, under Job artifacts, saves artifacts.zip. Unpack it, and it holds a public folder. A build from your own computer works too.
  4. Open site in NextExplorer and drag in everything inside public, so index.html sits directly in site.
  5. Marketplace → Start9 Pages → Install. Its task “Add your first website!” opens Manage Websites → Add: Name Site, Source NextExplorer, Folder Location Files/site → Save. Its two health checks, Hosting and Website Folders, go green.
  6. Marketplace → Tor → Install. On the Site interface page, select Add onion service, or use Tor → Actions → Manage Onion Services. You get an http and an https .onion address.
  7. Open the http one in Tor Browser, and publish that one. Onion connections are already encrypted end to end, and the https address is signed by your server’s own certificate authority, so readers would get a certificate warning.

An onion address is a public-key name too: it’s an Ed25519 key, and Tor Browser checks that the server holds it. No port is opened on your router, your home IP stays hidden, and it works behind CGNAT. It’s down whenever the box is.

3. The Pubky domain

There are two ways to serve it. Both use your key, and moving from the first to the second keeps the address only if the same key carries over (3.4).

  • A small VPS, working now. The key’s A record points at a VPS running nginx, and a cron job on the VPS republishes the record every hour. Pubky’s browser opens it over plain HTTP.
  • A Pubky homeserver, coming. The site sits in your storage on a Pubky homeserver, and Pubky’s browser opens it at https://KEY/, encrypted and checked against your key, with no server of yours to run. The homeserver gained this on 21 October 2026, but it isn’t in a release yet. Section 3.4 has what to watch for.

If you can wait, wait for the homeserver. It removes the VPS, the hourly job and the secret kept on the VPS, and it gives readers HTTPS checked against your key. Take the VPS route only if you want the Pubky domain live now. Its costs are a VPS of a few dollars a month, an hourly job, your key’s secret on that VPS, and plain HTTP: over HTTP the connection is neither encrypted nor proven to come from you, because the signed record proves only where the name points.

Your Start9 box isn’t used for this address. StartOS runs only packaged services, and no package for publishing these records turned up in Start9’s repositories as of October 2026. A box behind CGNAT, as many home connections are, also can’t take inbound connections without a tunnel.

3.1 Make the key

Do this on your own computer, where the seed’s backup will live.

  1. From the pkdns releases page on GitHub (github.com/pubky/pkdns, under Releases), download the newest release’s file ending -linux-amd64.tar.gz, or -linux-arm64.tar.gz for an ARM machine. The release lists no checksums of its own; GitHub shows each file’s SHA-256 digest beside it. Check yours matches:

    sha256sum pkdns-v*-linux-amd64.tar.gz
    

    It prints a 64-character digest and the file’s name. If the digest isn’t the one GitHub shows, delete the file and download it again.

  2. Unpack it and install the command-line tool:

    tar -xzf pkdns-v*-linux-amd64.tar.gz
    sudo install -m 755 pkdns-v*-linux-amd64/pkdns-cli /usr/local/bin/
    pkdns-cli --version
    

    The first two print nothing; the folder they leave holds pkdns and pkdns-cli. The last prints pkdns-cli and the version, such as pkdns-cli 0.7.1.

  3. Make the seed, the secret your domain is derived from:

    mkdir -p -m 700 ~/pkdns
    pkdns-cli generate > ~/pkdns/seed.txt
    chmod 600 ~/pkdns/seed.txt
    pkdns-cli publickey ~/pkdns/seed.txt
    

    The first three print nothing. The last prints your public key, 52 characters: that’s KEY, your domain. The seed is 52 characters too, so take care never to paste the wrong one anywhere.

  4. Back up seed.txt now, in your password manager and in your encrypted backups. Whoever holds the seed controls the domain, and there’s no registrar to appeal to: a lost seed is a lost address, and a leaked one is an address someone else can repoint.

3.2 Serve it from a VPS

  1. Rent a small VPS running Debian 13, with your SSH key for login. If your provider has a firewall, allow ports 22 (SSH) and 80 (HTTP).

  2. Log in to the VPS as an account that can use sudo. The first time, ssh asks whether to trust the VPS’s host key: compare the fingerprint with the one your provider’s console shows, if it shows one, before answering yes. Install the web server, rsync, cron and curl, and hand the web folder to your account:

    sudo apt install nginx rsync cron curl
    sudo chown -R VPS_USER: /var/www/html
    curl -sI http://localhost/ | head -1
    

    The install ends with Setting up nginx lines, or says each is already the newest version. The chown prints nothing. The curl prints HTTP/1.1 200 OK: nginx is serving its welcome page. Debian’s nginx serves /var/www/html to every name that reaches it, so it needs no configuration for your key.

  3. Lock the VPS down before your seed goes on it. Anyone who takes it over can repoint your domain, so it has to refuse password logins and install security updates by itself. On the VPS:

    sudo sshd -T | grep -i '^passwordauthentication'
    apt-config dump APT::Periodic::Unattended-Upgrade
    

    The first should print passwordauthentication no. If it prints yes, turn password logins off and run the check again:

    echo 'PasswordAuthentication no' | sudo tee /etc/ssh/sshd_config.d/00-no-passwords.conf
    sudo systemctl restart ssh
    

    tee prints the line it wrote; the restart prints nothing. The 00- keeps the file ahead of any your provider added, such as 50-cloud-init.conf, since ssh takes the first value it reads. Keep your session open until a second terminal logs in with your key.

    The second check should print APT::Periodic::Unattended-Upgrade "1";. If it prints nothing, run sudo apt install unattended-upgrades, then sudo dpkg-reconfigure -plow unattended-upgrades, and answer Yes when it asks whether to download and install stable updates automatically.

  4. From your computer, copy the built site (the public folder from 2.3) over, replacing the welcome page:

    rsync -a --delete public/ VPS_USER@VPS_IP:/var/www/html/
    

    It prints nothing. Run the rsync line again after each release.

  5. From your computer, copy the archive from 3.1 and the seed to the VPS, the seed into a private folder; the hourly job signs with it:

    scp pkdns-v*-linux-amd64.tar.gz VPS_USER@VPS_IP:
    ssh VPS_USER@VPS_IP 'mkdir -m 700 -p ~/pkdns'
    scp ~/pkdns/seed.txt VPS_USER@VPS_IP:pkdns/seed.txt
    

    Each scp prints a progress line for its file; the ssh line prints nothing. If the VPS is ARM, download its -linux-arm64.tar.gz file for it instead. Then, on the VPS, run 3.1’s step 2 to install pkdns-cli there.

  6. On the VPS, write the zone, a DNS zone file without the SOA record, as ~/pkdns/pkarr.zone in any editor:

    $TTL 60
    @      IN    A      VPS_IP
    
  7. Publish it:

    pkdns-cli publish ~/pkdns/seed.txt ~/pkdns/pkarr.zone
    

    It prints Packet KEY, a Name TTL Type Data heading, your A record, and then, after a few seconds of Hang on..., a timestamp and Successfully announced. A line starting Error means it failed, even though the command still exits with success, so read the output.

  8. Republish every hour, since the DHT forgets records within hours. On the VPS, run crontab -e, choose an editor if it asks, and add this line. Cron needs full paths, so it uses $HOME:

    17 * * * * /usr/local/bin/pkdns-cli publish $HOME/pkdns/seed.txt $HOME/pkdns/pkarr.zone >> $HOME/pkdns/publish.log 2>&1
    

    crontab -l then prints the line back. Each hour, publish.log gains the publish output, ending in a Successfully announced. line; check it now and then.

  9. Check from your computer:

    pkdns-cli resolve KEY
    nslookup KEY 34.65.109.99
    curl -sI --resolve KEY:80:VPS_IP http://KEY/ | head -1
    

    The first prints Resolve dns records of pk:KEY, your A record, and Last updated at: with a time; Failed to find the packet. means it isn’t visible yet, so wait a minute and try again. The second asks one of Pubky’s public PKDNS resolvers, and prints a Name: line with your key and an Address: line with VPS_IP. The third asks nginx for the site under your key’s name and prints HTTP/1.1 200 OK. Resolvers cache these records for five minutes or more, so changes take a while to show.

  10. Open http://KEY./ in Pubky’s browser. Keep the trailing dot, or the browser may search for the key instead of opening it.

3.3 What readers do

Readers have two ways to open the Pubky domain:

  • Pubky’s browser. It’s Chromium with PKDNS DNS-over-HTTPS on by default, and its builds come from github.com/pubky/pubky-chromium. They type http://KEY./. It’s new, and it hasn’t yet shown it keeps pace with Chrome’s security fixes, so offer it rather than press it.
  • Their own browser, with a PKDNS resolver. They set the browser’s secure DNS (DNS over HTTPS) to a custom provider, https://pkdns.pubky.org/dns-query, and type the same address. In Chrome that’s Settings → Privacy and security → Security → Use secure DNS; in Firefox, the DNS over HTTPS setting under Privacy & Security. The resolver then sees every name they look up, and passes ordinary names on to another resolver, so this suits a second browser better than a main one.

3.4 Moving it to a Pubky homeserver

When this ships, it replaces the VPS. As of 24 October 2026:

  • The homeserver. Website serving was merged on 21 October 2026, after the latest release (v0.16.2, of 15 October). Wait for a release that lists it.
  • An account. Homeservers need a signup token from their operator by default, unless you run your own. The homeserver republishes its users’ records every four hours by default, which replaces your cron job.
  • Uploading. The site goes into your storage under /pub/website/_/, through the storage API. Each file needs its content type set at upload, because the homeserver serves the type it stored, so a site of many files needs a script for this.
  • The records. You sign two: one routing your key to the homeserver, and one naming the key its TLS must prove. The homeserver’s docs say the SDK has no helper for them yet, and pkdns-cli writes only A, AAAA, TXT, CNAME and MX records, so it can’t write them.
  • The browser. On 16 October 2026, Pubky’s browser didn’t yet follow those records.
  • Your key. Moving needs the same key, or the address changes. Pubky Ring, Pubky’s key manager, imports a recovery phrase, an encrypted file or a QR code. Whether it takes a seed made with pkdns-cli isn’t documented, so check before you count on keeping the address.

Once all of that is in place: upload the site, publish the two records, check the address opens as https://KEY/ in Pubky’s browser, then remove the cron job and the seed from the VPS, and close the VPS.

4. Tell readers where to find it

Put the addresses on the site’s About page and in the repository’s README now, while readers can still reach you to learn them. For example:

This site lives at four addresses, all serving the same pages:

- https://www.example.org/ opens in any browser.
- https://GITHUB_USER.github.io/REPO/ is a copy on GitHub.
- http://ONION.onion/ opens in Tor Browser.
- http://KEY./ opens in Pubky's browser, or in a browser set to a PKDNS resolver.

Leave out the GitHub line if the GitHub copy has no Pages site.

5. Each release

  1. Push to GitLab. GitLab rebuilds www.example.org, the mirror carries the commit to GitHub within five minutes, and GitHub rebuilds its copy if it has a Pages workflow.
  2. Download the new artifacts.zip from GitLab’s pages job and unpack it.
  3. Onion copy: in NextExplorer, delete everything inside Files/site and drag the new public contents in. Expect a few seconds of 404s in between.
  4. Pubky domain: rsync -a --delete public/ VPS_USER@VPS_IP:/var/www/html/, which prints nothing.

6. When something is taken down

What’s goneWhat readers seeWhat you do
GitHubnothing changesremove the mirror, as gitlab-hosting-github-mirror says, and drop the GitHub line from the About page
GitLabyour domain stops serving the site6.1
The domainordinary browsers lose the main address6.2
The VPSthe Pubky domain stops answering6.3
Pubky’s public resolversPubky-browser readers who rely on them can’t resolve the key6.4
Your Start9 boxthe onion copy is downbring the box back; nothing else is affected
The seed leakssomeone else can repoint the Pubky domain6.5

6.1 GitLab drops you

Your clone and the GitHub mirror have every commit. Make GitHub the home as gitlab-hosting-github-mirror describes, and if the GitHub copy has a Pages site, move the domain to it:

  1. On GitHub: Settings → Pages → Custom domain → www.example.org → Save.

  2. At your DNS host, point www at GitHub and give the apex GitHub’s addresses in place of GitLab’s A record:

    www  CNAME  GITHUB_USER.github.io.
    @    A      185.199.108.153
    @    A      185.199.109.153
    @    A      185.199.110.153
    @    A      185.199.111.153
    @    AAAA   2606:50c0:8000::153
    @    AAAA   2606:50c0:8001::153
    @    AAAA   2606:50c0:8002::153
    @    AAAA   2606:50c0:8003::153
    
  3. After the TTL, dig +short www.example.org prints GITHUB_USER.github.io. and four 185.199.x.153 lines. GitHub says HTTPS can take up to an hour, and its Enforce HTTPS box up to 24 hours to become available; tick it when it does.

Section 1, step 8, verified the domain on your GitHub account, so no other GitHub repository can claim it in those minutes. From now on, refresh the onion and Pubky copies from a build on your own computer, since GitLab’s job artifacts are gone.

6.2 The domain is seized or lapses

  1. Readers who know the other addresses carry on: they’re on the About page.
  2. On GitLab, clear Primary domain, so the gitlab.io name serves the site again instead of redirecting to the lost domain.
  3. Buy a new domain, repeat section 1 with it, and update the About page and README.

6.3 The VPS is gone

The Pubky domain lives on: only its record points at a dead server. Rent another VPS, repeat 3.2 with the same seed and the new IP in pkarr.zone, and publish. Readers keep the same address; resolvers catch up within minutes.

6.4 Pubky’s public resolvers go

Pubky’s browser and the DoH setting in 3.3 use resolvers that Pubky runs. If they stop, the Pubky domain still exists on the DHT, but those readers can’t look it up. A reader, or you, can run pkdns as a resolver of their own: it’s the other program in the release archive, and its README covers running it.

6.5 The seed leaks

There’s no recovery. Make a new key as in 3.1, publish it from a fresh VPS, put the new address on the About page and README, and say plainly there that the old one is no longer yours.

Known gaps

  • pkdns-cli’s output here is read from its source at release v0.7.1, not from running it. Builds of its newer, unreleased code write the seed as 64 hexadecimal characters, which that code’s publickey command can’t read. Use a release.
  • The homeserver route in 3.4 is as Pubky’s repositories describe it on 24 October 2026, and parts of it are days old.
  • The Start9 steps follow Start9’s documentation for StartOS 0.4, and the GitLab and GitHub steps follow those sites’ documentation as of October 2026. Menus move.
  • Pubky’s censorship-resistance claims for the DHT come from the project’s own write-ups, not from independent study.
  • The search for a StartOS package that publishes these records covered Start9’s own repositories only.

Focus box: Wifi-bt-remove script

The wifi-bt-remove script removes Wi-Fi and Bluetooth from your laptop, and puts them back when you ask but only with wired networking. Wired networking keeps working the whole time. It’s for Debian or Devuan laptops with Intel Wi-Fi.

Why

Bloody twitter. So good but so addictive. If you’re dopamine is fried, you need something drastic; drastic solutions for drastic problems.

Switching Wi-Fi off from the panel takes one click, so switching it back on takes one click too. wifi-bt-remove uninstalls the Wi-Fi firmware and the Bluetooth service instead. Once you’ve rebooted, getting back online means plugging in a cable and reinstalling them. That friction is the point: it breaks the reflex, and that helps you get work done. It’s a speed bump, not a lock, since a cable still gets you online.

Solution

Verify the script. Ask an expert or your trusted AI.

Remove the wifi and bluetooth drives by running:

sudo ~/.local/bin/wifi-bt-remove remove && sudo reboot

That command lists what it will remove and asks proceed? [y/N]. Type y to go ahead; just pressing Enter stops it with nothing changed. Add -y to skip the question. The packages are removed and apt’s download cache emptied, so restore can’t reinstall them without the cable.

Until reboot, the radios are only switched off, and the Wi-Fi switch in the panel turns Wi-Fi back on in one click. After the reboot they stay off until you run restore using a wired connection. If it warns that rfkill is unavailable instead, the packages are still gone; the reboot switches the radios off.

Get out of the house. Don’t go to a place where there’s wired internet, so you’re not tempted to turn it back on.

Restore

Plug in a network cable, and run:

sudo ~/.local/bin/wifi-bt-remove restore && sudo reboot

The script

#!/bin/bash
# wifi-bt-remove, v12
#
# Remove the Wi-Fi and Bluetooth packages so the radios stop working, and
# reinstall them on request. There is no config file to edit.
#
#   sudo ~/.local/bin/wifi-bt-remove remove    remove them, then reboot
#   sudo ~/.local/bin/wifi-bt-remove restore   reinstall them, then reboot
#
# remove uninstalls the packages, which is the persistent part: after a reboot
# the Wi-Fi driver has no firmware to load and the Bluetooth service (bluez) is
# gone. It also soft-blocks both radios with rfkill, so they go off at once,
# but until that reboot the Wi-Fi switch in the panel turns Wi-Fi back on: the
# driver still holds the firmware it loaded at boot. rfkill is installed
# automatically if missing; if it cannot be made available, the packages are
# still removed and a reboot completes the shutdown. remove asks before it
# runs; -y skips the prompt.
#
# It uses apt remove, not apt purge, so settings files stay on disk and restore
# reuses them. It also empties apt's download cache: apt-get keeps a copy of
# every package it downloads, and restore would reinstall from that copy with
# no cable.
#
# restore unblocks the radios and reinstalls the packages. It pulls from the
# package archive, so a wired connection is needed while Wi-Fi is off. Reboot
# afterwards: the Wi-Fi driver found no firmware at boot and gave up on the
# card, and it only looks again at the next boot.
#
# The drivers live in the kernel and are not removed. Removing bluez also
# removes packages that depend on it (the bluetooth metapackage, blueman,
# gnome-bluetooth) when installed; restore does not bring those back.

set -euo pipefail

# Wi-Fi radio firmware and the Bluetooth service. Removing these two disables
# the radios; nothing else is required. Add iw or wireless-tools here to remove
# the Wi-Fi command-line tools too. wpasupplicant, the Wi-Fi login service, can
# go as well unless a wired link logs in with 802.1X. Before adding a package,
# run 'apt-get -s remove <package>' to see everything apt would take with it.
readonly -a PACKAGES=('firmware-iwlwifi' 'bluez')

ASSUME_YES=0

say()  { printf '%s\n' "$*"; }
warn() { printf 'warning: %s\n' "$*" >&2; }
die()  { printf '%s\n' "$*" >&2; exit 1; }

usage() {
    cat <<EOF
usage: ${0##*/} {remove [-y|--yes] | restore}
  remove    remove the Wi-Fi/Bluetooth packages (${PACKAGES[*]}) and switch the
            radios off (root), then reboot; -y skips the confirmation
  restore   reinstall the packages (root, needs wired link), then reboot
EOF
}
usage_die() { usage >&2; exit 2; }

require_root() { [[ ${EUID} -eq 0 ]] || die "needs root: sudo $0 $1"; }
require_apt()  { command -v apt-get >/dev/null || die "apt-get not found; this tool is apt-only"; }

# Make rfkill available, installing it if missing. Returns non-zero if it still
# is not usable, so the caller can fall back to advising a reboot. rfkill blocks
# the radio at the kernel level and works even while Wi-Fi is connected, so it
# needs no module unloading.
ensure_rfkill() {
    command -v rfkill >/dev/null && return 0
    say "installing rfkill (to switch the radios off without a reboot)..."
    DEBIAN_FRONTEND=noninteractive apt-get -y install rfkill >/dev/null \
        || warn "rfkill install failed"
    command -v rfkill >/dev/null
}

confirm() {
    local p
    say "these packages will be removed:"
    for p in "${PACKAGES[@]}"; do say "  ${p}"; done
    say "plus anything that depends on them (removing bluez also removes the"
    say "bluetooth metapackage, blueman, gnome-bluetooth when installed). the"
    say "drivers stay in the kernel; the radios go off right away if rfkill can be"
    say "installed, otherwise at the next reboot. reboot afterwards to keep wi-fi off."
    (( ASSUME_YES == 1 )) && return 0
    [[ -t 0 ]] || die "not a terminal and --yes not given; refusing to remove"
    local ans
    read -r -p "proceed? [y/N] " ans || true
    case "${ans}" in
        y|Y|yes|YES) return 0 ;;
        *) die "aborted; nothing was changed" ;;
    esac
}

cmd_remove() {
    require_root remove
    require_apt
    confirm

    say "removing: ${PACKAGES[*]}"
    DEBIAN_FRONTEND=noninteractive apt-get -y remove "${PACKAGES[@]}" \
        || die "removal failed. apt refuses all changes while any package is broken; if it names an unrelated one, fix that with 'sudo apt-get -f install' first, then rerun."

    # apt-get keeps what it downloads; restore must not find a copy here.
    say "emptying apt's download cache..."
    apt-get clean || warn "could not empty apt's download cache; run 'sudo apt-get clean' so restore needs the cable"

    if ensure_rfkill; then
        say "switching the radios off..."
        rfkill block wifi      || warn "could not block wifi"
        rfkill block bluetooth || warn "could not block bluetooth"
        say "done. reboot to make it stick; until then the wi-fi switch in the panel turns it back on."
    else
        warn "rfkill unavailable; the packages are removed but the radios are still powered."
        say "done. reboot to switch the radios off."
    fi
}

cmd_restore() {
    require_root restore
    require_apt

    # Unblock first, apart from apt. Until you reboot after remove, the Wi-Fi
    # driver still holds its firmware in memory, so Wi-Fi comes back even if
    # the reinstall fails; after that reboot, only another reboot following the
    # reinstall brings it back. Bluetooth needs bluez reinstalled either way.
    if command -v rfkill >/dev/null; then
        say "unblocking the radios..."
        rfkill unblock wifi      || warn "could not unblock wifi"
        rfkill unblock bluetooth || warn "could not unblock bluetooth"
    fi

    say "refreshing package lists..."
    apt-get update || warn "apt-get update did not fully succeed; continuing with cached lists"
    say "reinstalling: ${PACKAGES[*]}"
    DEBIAN_FRONTEND=noninteractive apt-get -y install "${PACKAGES[@]}" \
        || die "reinstall failed. check the wired connection; if apt names an unrelated broken package, run 'sudo apt-get -f install' first, then rerun."
    say "done. reboot to bring wi-fi and bluetooth back."
}

main() {
    case "${1:-}" in
        remove)
            shift
            local a
            for a in "$@"; do
                case "${a}" in
                    -y|--yes) ASSUME_YES=1 ;;
                    *) warn "unexpected argument: ${a}"; usage_die ;;
                esac
            done
            cmd_remove
            ;;
        restore)
            shift
            (( $# == 0 )) || { warn "unexpected argument: $1"; usage_die; }
            cmd_restore
            ;;
        ''|help|-h|--help) usage ;;
        *)  warn "unknown command: ${1}"; usage_die ;;
    esac
}

main "$@"

Focus box with wifi

If you don’t need the internet at all while you work, turn the radios off and be done. But if you need say a dictionary or a an AI, then the solution is a laptop that reaches only those sites and no others. Everything else is dropped before it leaves the machine, by the kernel, not by a browser extension you can switch off. Undoing it takes three passwords that live on a sheet of paper outside the house. The rest of the laptop stays an ordinary Devuan desktop: LibreOffice, the file manager, all of it.

Already running Devuan XFCE? Do section 1, skip section 2, and carry on from section 3.

The paper gets three lines, in the order the build produces them:

  1. Firmware supervisor password (section 1)
  2. GRUB password (section 9)
  3. Root password (section 10)

The disk encryption passphrase does not go on the paper; you type it at every boot.

Do the sections in order. Everything root does, do on the text console: press Ctrl+Alt+F1, log in as root. If root has no password yet because the installer never asked for one, log in there as your own user, run sudo -i, then passwd to give root a temporary one. Ctrl+Alt+F7 returns to the desktop.

0. What you need

  • The ThinkPad with Devuan XFCE on it, or a USB stick of 4 GB or more and another Linux computer to write the installer with.
  • Your Wi-Fi passphrase.
  • A sheet of paper and a pen. Nothing goes in a password manager.

1. Firmware, first visit

Generate the supervisor password on a computer first:

tr -dc 'a-km-z2-9' </dev/urandom | head -c 12; echo

Expected: 12 lowercase letters and digits with no 0, 1 or l, so nothing is ambiguous on paper. Write it on the paper as line 1.

Power on the ThinkPad and press F1 at the logo. Menu names vary a little between generations; if an item is missing, use the nearest one.

  1. Security → Password → Supervisor Password: set it to line 1. If the firmware rejects the length, shorten it to what it accepts.
  2. Security → I/O Port Access: leave Wireless LAN enabled; set Wireless WAN and Bluetooth to Disabled. Leave USB Port enabled; a keyboard needs it. (To tie the box to a cable instead, set Wireless LAN to Disabled too; nothing else in this document changes.)
  3. Leave the Startup menu alone for now; USB boot stays on until section 11.
  4. F10, save, exit.

2. Install Devuan

Skip this section if Devuan is already on the machine.

On the other computer:

  1. Open https://www.devuan.org/get-devuan and follow it to the desktop-live directory of the current stable release. Download three files: the amd64 ISO with desktop-live in its name, SHA256SUMS, and SHA256SUMS.asc.

  2. Check the download:

    sha256sum -c SHA256SUMS 2>/dev/null | grep desktop-live
    

    Expected: one line ending in : OK. FAILED means download it again.

    If that machine is Devuan, also check the signature:

    gpg --no-default-keyring --keyring /usr/share/keyrings/devuan-keyring.gpg --verify SHA256SUMS.asc SHA256SUMS
    

    Expected: a line beginning gpg: Good signature from "Devuan. A WARNING: This key is not certified with a trusted signature! after it is normal. BAD signature means stop. No public key means the signing key is not in that keyring; the checksum match above is then the check you have.

  3. Find the stick:

    lsblk -d -o NAME,SIZE,MODEL
    

    Expected: one line per disk. The stick is the one with the matching size and model; note its name, for example sdb. The next command destroys whatever is on the device you name, and naming your own disk destroys your system.

  4. Write it, with sdX replaced:

    sudo dd if=devuan_*desktop-live.iso of=/dev/sdX bs=4M status=progress oflag=sync
    

    Expected: a progress line that updates, then three lines ending in copied. Remove the stick.

On the ThinkPad:

  1. Power on, press F12 at the logo, choose the stick. It boots to a live XFCE desktop. Join your Wi-Fi from the network icon in the panel, then start the installer from its desktop icon.

  2. Take the defaults, and when it asks:

    • Disk: the whole internal disk, with encryption if it offers it. The passphrase protects the notes on the disk if the laptop is lost; it plays no part in the focus control, so make it one you can type every morning.
    • Root password: a temporary one you can type easily. Section 10 replaces it.
    • User: your name, username john, the password you’ll use daily.
    • Hostname: focus.
  3. Reboot when asked and remove the stick. Expected: the passphrase prompt if you chose encryption, then the LightDM login screen. Log in as john once, join your Wi-Fi from the panel icon so the box remembers it, and log out.

3. Packages

On the console as root:

apt update

Expected: lines beginning Get: and Hit:, then Reading package lists... Done. If every line is an error, the box isn’t online; log in to the desktop and check the network icon.

See whether a time daemon is already running:

ps -eo user,comm | grep -E 'ntp|chrony'

Expected: either a line like ntpsec ntpd or _chrony chronyd, or nothing. If a line appears, note the user name in its first column, you need it in section 5, and leave chrony out of the next command.

apt install squid nftables chrony curl

Expected: a package list, Do you want to continue? [Y/n], Enter, then lines beginning Processing triggers. A package that is already installed just gets a line saying it is already the newest version. curl is only for the tests in sections 4 and 5.

Check the service users the firewall will name:

id proxy; id _chrony

Expected: two lines beginning uid=, the first containing (proxy), the second (_chrony). If you kept an existing time daemon instead, run id on its user name.

4. The proxy and the allowlist

Put Squid’s shipped config aside:

mv /etc/squid/squid.conf /etc/squid/squid.conf.shipped

Expected: nothing.

Create the allowlist:

vi /etc/squid/allowlist
.lsbible.org
.merriam-webster.com
.claude.ai
.anthropic.com
challenges.cloudflare.com

A leading dot means the domain and everything under it. challenges.cloudflare.com is the human-check that claude.ai’s login uses.

Create the config:

vi /etc/squid/squid.conf
# Focus box proxy. Listens on loopback only.
http_port 127.0.0.1:3128

acl allowed dstdomain "/etc/squid/allowlist"
acl SSL_ports port 443
acl Safe_ports port 80 443
acl CONNECT method CONNECT

http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access allow localhost allowed
http_access deny all

cache deny all
via off
forwarded_for delete

Check that it parses:

squid -k parse

Expected: a run of lines containing Processing:, one per line of the config, and no line containing FATAL or ERROR. Lines with WARNING are fine.

Restart it:

service squid restart

Expected: a line with [ ok ] ending in squid..

Test a listed site and an unlisted one through the proxy:

curl -sI -x http://127.0.0.1:3128 https://read.lsbible.org | head -n 1
curl -sI -x http://127.0.0.1:3128 https://x.com | head -n 1

Expected: first HTTP/1.1 200 Connection established, second HTTP/1.1 403 Forbidden. The 200 is Squid opening the tunnel, which is what is being tested here; the site’s own reply comes after it.

5. The firewall

Create the ruleset. If section 3 found an existing time daemon, put its user name in place of _chrony.

vi /etc/nftables.conf
#!/usr/sbin/nft -f
flush ruleset

table inet focus {
    chain output {
        type filter hook output priority 0; policy drop;

        # Local traffic, including the browser talking to the proxy.
        oifname "lo" accept

        # Packets on connections already allowed below.
        ct state established,related accept

        # The proxy may look up names and reach web ports.
        meta skuid "proxy" meta l4proto { tcp, udp } th dport 53 accept
        meta skuid "proxy" tcp dport { 80, 443 } accept

        # The clock may look up names and reach time servers.
        meta skuid "_chrony" meta l4proto { tcp, udp } th dport { 53, 123 } accept

        # Root: apt, the nightly update and NetworkManager. Root's password
        # lives on the paper.
        meta skuid "root" accept
    }
}

Load it:

nft -f /etc/nftables.conf

Expected: nothing. Any output is an error; the usual one names a user that doesn’t exist, which means section 3 didn’t finish.

Look at it:

nft list ruleset

Expected: the table printed back, with policy drop on the chain line. The user names may come back as numbers; either is fine.

Make it load at boot, before any interface comes up:

vi /etc/network/if-pre-up.d/nftables
#!/bin/sh
# Load the focus firewall before any network interface comes up.
# If this fails, the interface stays down: no rules means no network.
exec /usr/sbin/nft -f /etc/nftables.conf
chmod 755 /etc/network/if-pre-up.d/nftables

Expected: nothing. The file name must contain no dot, or run-parts skips it. The hook runs when the loopback interface comes up at boot, which happens before NetworkManager starts the Wi-Fi, so the rules are in the kernel before the radio is.

Test it. First as root:

ping -c 1 -W 2 1.1.1.1

Expected: 1 packets transmitted, 1 received, 0% packet loss. Root is allowed out.

Now as john:

su - john
ping -c 1 -W 2 1.1.1.1

Expected: 1 packets transmitted, 0 received, 100% packet loss. That loss is the firewall working: john’s packets are dropped.

curl -sS -m 5 https://read.lsbible.org

Expected: an error containing timed out or Could not resolve host, and no page. John cannot even look up a name.

curl -sI -m 10 -x http://127.0.0.1:3128 https://read.lsbible.org | head -n 1
exit

Expected: HTTP/1.1 200 Connection established. Through the proxy, john reaches listed sites and nothing else.

Reboot. At the login screen press Ctrl+Alt+F1, log in as root, and run nft list ruleset again. Expected: the table. If it prints nothing, the hook didn’t run; ls -l /etc/network/if-pre-up.d/ should show the file with -rwxr-xr-x.

6. Firefox

One system-wide policy file: proxy fixed, DNS-over-HTTPS off, Firefox’s own phone-home off, autoplay off.

mkdir -p /usr/lib/firefox-esr/distribution
vi /usr/lib/firefox-esr/distribution/policies.json
{
  "policies": {
    "Proxy": {
      "Mode": "manual",
      "HTTPProxy": "127.0.0.1:3128",
      "UseHTTPProxyForAllProtocols": true,
      "Locked": true
    },
    "DNSOverHTTPS": { "Enabled": false, "Locked": true },
    "CaptivePortal": false,
    "NetworkPrediction": false,
    "DisableTelemetry": true,
    "DisableFirefoxStudies": true,
    "DisablePocket": true,
    "Preferences": {
      "media.autoplay.default": { "Value": 5, "Status": "locked" },
      "network.connectivity-service.enabled": { "Value": false, "Status": "locked" }
    }
  }
}

Check the JSON, since one wrong comma makes Firefox ignore the whole file:

python3 -m json.tool /usr/lib/firefox-esr/distribution/policies.json >/dev/null && echo OK

Expected: OK. Anything else names the bad line.

Ctrl+Alt+F7, log in as john, open Firefox and go to about:policies. Expected: a table headed Active listing Proxy, DNSOverHTTPS and the rest. If it says the policy service is inactive, move the file as root to /etc/firefox/policies/policies.json (mkdir -p /etc/firefox/policies first), restart Firefox and look again.

7. Check the sites, add what they need

As john, use each site for a minute: open a chapter, look up a word, log in to claude.ai with the emailed code (not with Google: accounts.google.com is not on the list) and send a message.

If a site works, it is done, even with a logo or an ad missing. If something is broken, see what Squid refused. On the console as root:

grep TCP_DENIED /var/log/squid/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -n 30

Expected: a count and a host per line, most frequent first, like 14 www.google-analytics.com:443. Hosts under mozilla.com, mozilla.net and firefox.com are Firefox phoning home. Hosts with ads, analytics, tracking, pixel or doubleclick in the name are what this box exists to drop. Add a host only when the site cannot work without it, as a dotted line in /etc/squid/allowlist, then:

squid -k reconfigure

Expected: nothing. Reload the page.

8. Nightly updates

Root may reach the network, so a root cron job keeps the box patched without you.

vi /etc/cron.daily/upgrade
#!/bin/sh
# Nightly security updates. Root is the only account that reaches the
# network directly, and root's password lives on the paper.
export DEBIAN_FRONTEND=noninteractive
{
    apt-get -qq update
    apt-get -qq -y -o Dpkg::Options::=--force-confold upgrade
} >>/var/log/nightly-upgrade.log 2>&1
chmod 755 /etc/cron.daily/upgrade
run-parts --test /etc/cron.daily

Expected: a list of paths that includes /etc/cron.daily/upgrade. --force-confold keeps your edited config files when a package ships a new version.

The desktop install includes anacron, which runs the job at the next boot when the laptop was off at the scheduled hour. Confirm:

dpkg -l anacron | grep ^ii

Expected: one line beginning ii anacron. If nothing prints, apt install anacron. A kernel update takes effect at the next reboot, so reboot now and then.

9. Lock the bootloader

Without this, anyone at the keyboard presses e at the GRUB menu, adds init=/bin/sh, and has a root shell in a minute.

Generate the GRUB password and write it on the paper as line 2:

tr -dc 'a-km-z2-9' </dev/urandom | head -c 20; echo

Expected: 20 characters.

grub-mkpasswd-pbkdf2 | tee /root/grub-hash

Expected: Enter password: and Reenter password: (type line 2 twice; nothing echoes), then one long line beginning PBKDF2 hash of your password is grub.pbkdf2.sha512., which is also saved to /root/grub-hash.

{ echo 'set superusers="admin"'; echo "password_pbkdf2 admin $(grep -o 'grub\.pbkdf2\..*' /root/grub-hash)"; } >> /etc/grub.d/40_custom
rm /root/grub-hash
tail -n 2 /etc/grub.d/40_custom

Expected: the two lines, the second ending in the long hash.

Turn off the recovery entry, which would otherwise offer a root shell without a password. Open /etc/default/grub, find #GRUB_DISABLE_RECOVERY="true" and remove the #. Then:

grep ^GRUB_DISABLE_RECOVERY /etc/default/grub

Expected: GRUB_DISABLE_RECOVERY="true".

Mark the normal entry as bootable without a password, so a normal boot asks nothing and only editing asks:

sed -i 's/^CLASS="--class gnu-linux/CLASS="--unrestricted --class gnu-linux/' /etc/grub.d/10_linux
grep '^CLASS=' /etc/grub.d/10_linux

Expected: CLASS="--unrestricted --class gnu-linux --class gnu --class os".

update-grub

Expected: Generating grub configuration file ..., Found linux image:, Found initrd image:, done. A line saying os-prober will not be executed is normal.

grep -c -- --unrestricted /boot/grub/grub.cfg

Expected: 1 or more.

Reboot. The menu appears and boots by itself after a few seconds. To see the lock, reboot once more and press e on the entry: expected Enter username:. Press Esc and let it boot.

10. Seal

In order, on the console as root.

  1. Last chance for packages: apt install anything still missing.

  2. Remove sudo and take john out of the sudo group. A desktop install usually has both.

    apt purge sudo
    deluser john sudo
    groups john
    

    Expected: apt removes sudo (or says it is not installed); deluser prints Removing user 'john' from group 'sudo' ... (or that john is not a member); groups prints john : john followed by groups like cdrom audio video plugdev netdev, with no sudo.

  3. Make the config files immutable:

    chattr +i /etc/nftables.conf /etc/network/if-pre-up.d/nftables /etc/squid/squid.conf /etc/squid/allowlist /etc/grub.d/40_custom
    lsattr /etc/squid/allowlist
    

    Expected: an attribute string containing i, like ----i---------e------- /etc/squid/allowlist. Root must now chattr -i a file before editing it: no accidental edits, no quick ones.

  4. Replace root’s password. Generate it, write it on the paper as line 3, then set it:

    tr -dc 'a-km-z2-9' </dev/urandom | head -c 32; echo
    passwd
    

    Expected: New password:, Retype new password:, passwd: password updated successfully.

  5. Prove the paper works before it leaves. exit, then log in as root from line 3. Expected: a root prompt. Reboot, press e at the GRUB menu, enter username admin and line 2. Expected: the entry opens for editing; press Esc and let it boot. If either fails, fix it now; after section 11 a failure means a screwdriver.

  6. From now on you log in only as john at the LightDM screen.

11. Firmware, second visit

Reboot, F1, enter line 1.

  1. Startup → Boot: in the boot priority order, exclude every USB and network entry (highlight it, press the key the screen names for exclude, usually x) so only the internal drive remains. If there is a Boot Order Lock item, set it On.
  2. Startup → Boot Device List F12 Option: Off.
  3. F10, save.

The machine now boots only its own disk. The firmware opens with line 1, GRUB editing with line 2, root with line 3.

12. The paper

Fold it, seal it in an envelope, and give it to the person or the place you chose. The whole build is worth exactly the distance to this envelope.

13. Daily use

Log in as john. The three sites load; every other address shows Squid’s Access Denied page, which is the normal face of this box. Missing images, broken ads and an empty new-tab page are normal too. Wi-Fi networks are joined from the panel icon as usual; the firewall doesn’t care which network the box is on.

Adding a site later

Needs the envelope. On the console as root:

chattr -i /etc/squid/allowlist
echo '.example.org' >> /etc/squid/allowlist
chattr +i /etc/squid/allowlist
squid -k reconfigure

Expected: nothing from each command. Keep a wishlist file in your home where you note sites as you miss them, so the envelope trip happens once in a while and not per site.

What still gets through

  • claude.ai is the one listed site that shows other people’s content: asked to, it fetches and summarises pages. Turn web search off in its settings, or accept that.
  • Pulling the drive and editing it in another computer. An hour of deliberate work; that is the floor on hardware you own.
  • The envelope. Everything above it is near-certain; the distance to it is the result.

14. If something is off

  • No site loads: the panel icon shows whether the box is on a network. If it is, service squid status as root should say running; service squid start if not.
  • Certificate errors on every site: the clock is wrong. date as john shows it. As root, chronyc tracking should show Leap status : Normal (for ntpsec, ntpq -p should list servers with a * on one line); if not, the time daemon’s rule in section 5 isn’t loaded or names the wrong user.
  • A site that used to work breaks after an update: squid -k parse and nft -f /etc/nftables.conf as root; both print no errors when the configs are intact.

Stopwatch pomodoro tracker

Track the time you work. Use another app or the sw stopwatch+pomodoro script below. If you can’t measure it, you can’t improve it.

The one idea

sw is an instrument of self-honesty, and everything below follows from that.

It measures one thing with total accuracy: the wall-clock span between the keypress that starts a session and the keypress that stops it. Not the time you were at the keyboard, not the time you were “productive,” just clock time from start to stop. That makes it good for exactly one job, seeing where your hours actually go, and useless for any job that needs the number to mean something to someone other than you. The three warnings at the end are not separate rules; they are one rule, that a log built on self-honesty survives only when no one with power over you reads it, you do not turn it into a stick to beat yourself with, and you are not hiding from what it shows.

What it does

Three modes, one log.

  • A stopwatch that counts up from zero until you stop it (sw, the default).
  • A pomodoro that counts a fixed focus block down, then runs a break (sw pomo).
  • A work log that every finished session writes to, which the reporting commands read back.

Each finished session is one row: the date, the clock time you stopped, the duration, and an optional tag. The reports are just arithmetic over those rows.

Driving it

Start the stopwatch, optionally tagged:

sw                 # untagged
sw deep work       # tagged: deep-work

Press Enter to stop and log the session. Press Ctrl+C to throw it away and log nothing.

Run a pomodoro:

sw pomo deep work

It shows a live countdown, logs the block when it completes, then starts a break. Enter during the work phase stops early and logs the partial session; Enter during the break skips the break. Ctrl+C during the break only ends the break, because the block before it is already logged.

Record or fix sessions by hand:

sw add 1h30m deep work   # log a session that ended now
sw amend 45m             # rewrite the last session's duration
sw sub 10m               # reduce the last session (bare `sw sub` removes it)

Run sw help for the rest: sum, last, regen, the clears, and sound.

Why a closed lid still counts

Because sw measures wall-clock, it keeps counting while the machine is suspended, the lid is shut, or the screen has blanked. Close the lid for ten minutes mid-session and those ten minutes land in the log.

This is deliberate, and usually right. A blank screen is not proof you stopped working: you might be reading on paper, thinking, or on a call, and that is real work the keyboard never saw. But it cuts both ways, and that is the whole discipline the tool asks of you. The same property that correctly counts your offline thinking also counts the lunch you walked away from without stopping the timer. sw cannot tell the two apart, so the one habit it requires is simple: stop it when you actually stop.

For pomodoros specifically, a suspend counts toward the focus block, and a completed pomodoro logs its set length, so a nap mid-session does not inflate the logged focus.

What a crash costs

The boundary is clean, so you can trust the history.

Everything already logged is on disk and safe no matter how the machine dies. A clean logout, a closed terminal, or a kill commits the session you are in the middle of before exiting. Ctrl+C discards the in-progress session on purpose. Only a hard power loss or a forced kill (SIGKILL) loses anything, and the most it can ever take is the single session you were running, never the log behind it. If a finished session can’t be saved, say because the disk is full, sw tells you why and prints the sw add command that puts it back.

Use it as a calibration, not a regime

The point of sw is not a number that goes up. People are bad at estimating where their hours go, so the tool earns its keep by correcting that estimate. Run it honestly for a week or two, find out where the time actually went, adjust, and then you can put it down. Treating it as a permanent scoreboard is how a measurement turns into the two failure modes below.

Who it is not for

Not for employers, and not for any audience with power over you

The moment the log flows to someone who can reward or punish you by it, it stops being a measurement. This is Goodhart’s law: when a measure becomes a target, it stops measuring anything. Mandated self-tracking produces padding, presenteeism, and clock theater, and the number drifts away from the work it was meant to reflect. sw only works pointed at yourself, because only then do you have a reason to be honest with it.

Not as a whip, for the flow worker or the obsessive

For deep, absorbed work, the stopwatch is the wrong instrument. Starting and stopping it breaks the absorption, and you forget it anyway, so it under-measures the work you most care about; keep it for fragmented and administrative time, not deep sessions. And if you are already prone to optimising yourself, watch the opposite failure: you will not forget the stopwatch, you will micromanage it until it becomes a compulsion and a way to punish yourself. That is Goodhart’s law again, aimed inward, the same failure as the employer case turned on yourself. The number is an approximate proxy for attention, not a score; treat it as one.

Not for what you are afraid to see

The tool runs on honesty, so it fails the instant part of you dreads the result. If you are scared of what the log will show, you will skip sessions, round the duration down, or quietly stop tracking, and the log becomes a source of shame instead of information. The audience you fear here can be an employer or your own inner critic, and the failure is the same either way. Track what you actually want to understand, not what you are bracing to be judged for.

A couple of edges worth knowing

  • A session is logged against the day you stop it, so a timer left running across midnight lands wholly on the new day. If you split your days at midnight and the boundary matters, stop the session before midnight and start a fresh one after.
  • A session shorter than a minute isn’t logged, because it’s almost always an accidental start. sw add and sw amend refuse anything under a minute for the same reason.
  • wlog.md shows each session as a start–end range, worked back from the time you stopped it and rounded to the minute. A pomodoro that finishes while the machine is asleep is stamped when it wakes, so its range ends at wake-up rather than when the block actually finished.
  • sw needs a real terminal for its timers; with no readable /dev/tty it refuses to start.
  • You can fix the raw log by hand: one session per line, with the date, the time you stopped, the seconds, and a one-word tag. sw regen names any line it can’t read, and that line stays out of the totals until you fix it.

Installing

Deploy through your install_scripts, mapping the source file to the command name sw. The script then lives at ~/.local/bin/sw. It is a Python 3 script that uses only the standard library, so there is nothing else to install. Settings (daily target, working days per week, pomodoro work and break minutes) and the readable log live in wlog.md; the settings at its top are what you edit to change configuration, not the script. The raw log, ~/.stopwatch_log, is the source of truth; sw regen rebuilds wlog.md from it.

The script

#!/usr/bin/python3
# sw, v30
#
# Stopwatch, pomodoro, and work log.
#
# Subcommands (run `sw help` for details):
#   sw [tag]                   run the stopwatch (default)
#   sw pomo [tag]              pomodoro timer
#   sw break [mins]            standalone break timer
#   sw sum                     today's total + work-length session count
#   sw last                    print the last logged session
#   sw add DURATION [tag]      record a session ending now
#   sw amend DURATION          rewrite the last entry's duration
#   sw sub [DURATION]          reduce the last entry, or remove it (no arg)
#   sw clear [-y]              wipe the raw log
#   sw clear-before DATE [-y]  delete entries before DATE
#   sw regen                   rebuild wlog.md from the raw log
#   sw sound                   test the alert sounds
#   sw help / version
#
# Python 3, standard library only.

import datetime
import fcntl
import locale
import os
import re
import select
import shutil
import signal
import subprocess
import sys
import tempfile
import termios
import time

# ---- defaults (used only when wlog.md lacks the corresponding config key) ----

DEFAULT_DAILY_TARGET = 8
DEFAULT_WORKING_DAYS = 5
DEFAULT_WORK_MINS = 25
DEFAULT_BREAK_MINS = 5

# Freedesktop stereo sounds. Override via env, or by editing these paths.
DEFAULT_SOUND_SESSION_END = "/usr/share/sounds/freedesktop/stereo/message.oga"
DEFAULT_SOUND_BREAK_END = "/usr/share/sounds/freedesktop/stereo/complete.oga"


class Config:
    daily_target = DEFAULT_DAILY_TARGET
    working_days = DEFAULT_WORKING_DAYS
    work_mins = DEFAULT_WORK_MINS
    break_mins = DEFAULT_BREAK_MINS


CFG = Config()

# Set by _init_paths() from the environment.
RAW_LOG = MD_LOG = LOCK_DIR = LOCK_FILE = ""
SOUND_SESSION_END = SOUND_BREAK_END = ""
LOCK_DIR_IS_FALLBACK = False

HELP_TEXT = r"""sw — stopwatch + pomodoro + work log

Usage
  sw [tag]                       stopwatch (default); live elapsed timer,
                                 Enter stops & logs, Ctrl+C cancels
  sw pomo [tag]                  pomodoro timer (live countdown, then a break)
  sw break [MINUTES]             standalone break timer (default = break mins)
  sw sum                         today's total + work-length session count
  sw last                        print the last logged session
  sw add DURATION [tag]          record a session of DURATION ending now
  sw amend DURATION              rewrite the last entry's duration
  sw sub [DURATION]              reduce the last entry by DURATION, or remove it
  sw clear [-y]                  wipe the raw log (dry-run without -y)
  sw clear-before DATE [-y]      delete entries before DATE (dry-run without -y)
  sw regen                       rebuild wlog.md from the raw log
  sw sound                       play both sounds for volume/preview check
  sw help                        print this help
  sw version                     print identity string

  --help, -h, --version are accepted as aliases.

Stopping and logging
  The stopwatch and the pomodoro share one live timer and log to the same
  place, so the day's total accrues whichever you run. Enter stops and logs
  (for the pomodoro work timer, the partial session); a pomodoro also logs on
  its own when the countdown completes. Ctrl+C cancels with nothing logged.
  A logout, a terminal close, or `kill` (SIGHUP/SIGTERM) commits the running
  session before exiting rather than dropping it; only Ctrl+C discards.
  Ctrl+C during a pomodoro's break only ends the break: the pomodoro before
  it is already logged. If a finished session can't be saved, sw says why
  and prints the `sw add` command that restores it.
  A session under a minute isn't logged: it's almost always an accidental
  start. For the same reason `sw add` and `sw amend` refuse durations under
  1m, `sw sub` won't leave an entry under 1m, and rows under a minute already
  in the raw log are left out of every total.

Pomodoro flow
  Work timer counts down → session-end chime → break timer counts down →
  break-end chime + desktop notification. Enter skips the break early.

DURATION accepts: 90m, 1h, 1h30m, 5400s, or a bare integer (= minutes).
DATE accepts: YYYY-MM-DD (e.g. 2026-05-12) or YYYYMMDD (e.g. 20260512).

Tag: a single positional, joined with `-` if multi-word.
  sw                             untagged stopwatch
  sw theology                    tagged: theology
  sw deep work                   tagged: deep-work
  sw pomo deep work              pomodoro, tagged: deep-work
  sw -- break                    `--` ends subcommand matching: stopwatch
                                 tagged `break` (for tags that collide with
                                 subcommand names)
  Tag words can't begin with `-`: a mistyped flag dies instead of being
  silently logged as tag text.
  Renaming a tag: change the tag field (the last column) directly in the raw
  log ~/.stopwatch_log, which is the source of truth, not in wlog.md (a
  generated view that gets overwritten on the next rebuild). Refresh as
  elsewhere: `sw regen`, or just let the next logged session rebuild wlog.md.
  A tag can't contain spaces.

Files (defaults; override with environment variables)
  ~/.stopwatch_log               raw log, one session per line: date, HH:MM
                                 (when it ended), seconds, tag, separated by
                                 tabs or spaces; `sw regen` names any line it
                                 can't read
                                 override: SW_RAW_LOG
  ~/Documents/mv/wlog.md         readable summary, each session shown as
                                 start–end and rounded to the minute; rebuilt
                                 on every change (or on demand with `sw regen`
                                 after a hand-edit)
                                 override: SW_MD_LOG

Config (in wlog.md, above the `# log` line; seeded on first run inside a code
fence, which is optional; missing keys use defaults; a line whose value can't
be read is a hard error, not a silent fallback, so trailing notes after a
value break it):
  Daily target: 8h               default 8; integer >= 1; `h` suffix optional
  Working days per week: 5       default 5; integer 1-7
  Pomodoro work: 25m             default 25; integer minutes >= 1; `m` optional
  Pomodoro break: 5m             default 5; integer minutes >= 1; `m` optional

Sounds (override with SW_SOUND_SESSION_END / SW_SOUND_BREAK_END, or edit the paths):
  Pomo session-end: message.oga  short chime when a pomodoro work timer completes
  Break-end: complete.oga        longer chime + desktop notification when any
                                 break ends (pomodoro break or standalone `sw break`)

Concurrency
  Mutating ops (session commit, add, amend, sub, clear, clear-before, regen)
  acquire an exclusive flock on ${XDG_RUNTIME_DIR:-$HOME/.cache}/sw-${UID}.lock
  so two parallel invocations can't corrupt the log or wlog.md. Read-only ops
  (last, sum, help, break, dry-run clears) don't lock.

Drawbacks
  - The timer must be started and stopped manually. If you forget:
    `sw add 90m tag`              for a missed session that ended around now
    `sw amend 1h30m`              to fix the last entry's duration
    `sw sub 20m` / `sw sub`       to trim the last entry, or drop it entirely
    Anything else: edit the raw log directly, then `sw regen`
    (format: date<TAB>HH:MM<TAB>seconds<TAB>tag).
  - `sw amend` and `sw sub` change the duration but keep the logged end time,
    so in wlog.md it's the start that moves. After a forgotten stop, fix the
    HH:MM in the raw log as well if you want the range to be right.
"""

# ---- output ----


def _write(stream, text):
    # Terminal output is best effort: after a logout the terminal is gone, and
    # a session being committed on SIGHUP must still reach the log.
    if stream is None:
        return
    try:
        stream.write(text)
        stream.flush()
    except (OSError, ValueError):
        pass


def say(msg=""):
    _write(sys.stdout, msg + "\n")


def warn(msg):
    _write(sys.stderr, msg + "\n")


def die(msg):
    warn(msg)
    sys.exit(1)


# ---- formatting (integer division truncates toward zero) ----


def _tdiv(a, b):
    q = abs(a) // abs(b)
    return q if (a < 0) == (b < 0) else -q


def _tmod(a, b):
    return a - b * _tdiv(a, b)


# Whole minutes, half a minute rounding up. Applied once to each session when
# the log is read, and every figure sums the results, so a day's sessions add
# up to its total and the terminal agrees with wlog.md.
def round_mins(secs):
    return (secs + 30) // 60


# The reports' one duration format. Zero-padded minutes keep columns aligned,
# and the h/m units keep a duration from reading as a clock time.
def fmt_mins(m):
    return "%dh %02dm" % (m // 60, m % 60)


# The same format to the second, for what the reports don't round: a duration
# the user typed (`sw sub 90s`) and a raw row too short to count.
def fmt_secs(s):
    m, sec = divmod(s, 60)
    return fmt_mins(m) + (" %02ds" % sec if sec else "")


# Clock for the live timer line: MM:SS under an hour, H:MM:SS at or above one.
def fmt_clock(s):
    if s >= 3600:
        return "%d:%02d:%02d" % (_tdiv(s, 3600), _tdiv(_tmod(s, 3600), 60), _tmod(s, 60))
    return "%02d:%02d" % (_tdiv(s, 60), _tmod(s, 60))


# ---- argument parsing ----

_DUR_HM = re.compile(r"([0-9]+)h([0-9]+)m?")
_DUR_ONE = re.compile(r"([0-9]+)([hms])")
_DIGITS = re.compile(r"[0-9]+")


# Seconds for 90m, 1h, 1h30m, 5400s, or a bare integer (minutes); None if the
# text isn't one of those or comes to zero.
def parse_duration(d):
    m = _DUR_HM.fullmatch(d)
    if m:
        total = int(m.group(1)) * 3600 + int(m.group(2)) * 60
    else:
        m = _DUR_ONE.fullmatch(d)
        if m:
            total = int(m.group(1)) * {"h": 3600, "m": 60, "s": 1}[m.group(2)]
        elif _DIGITS.fullmatch(d):
            total = int(d) * 60
        else:
            return None
    return total if total > 0 else None


# YYYY-MM-DD or YYYYMMDD to YYYY-MM-DD; None for other shapes or impossible
# dates such as 2026-02-30.
def normalize_date(d):
    if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", d):
        n = d
    elif re.fullmatch(r"[0-9]{8}", d):
        n = "%s-%s-%s" % (d[0:4], d[4:6], d[6:8])
    else:
        return None
    try:
        datetime.date(int(n[0:4]), int(n[5:7]), int(n[8:10]))
    except ValueError:
        return None
    return n


# Joins the words with `-` (runs of whitespace and dashes collapse to one) and
# returns `-`, the in-band "no tag" marker, when nothing is left. A leading `--`
# is consumed; any other word starting with `-` is fatal, so a mistyped flag
# dies loudly instead of being committed to the log as tag text.
def args_to_tag(args):
    if args and args[0] == "--":
        args = args[1:]
    for a in args:
        if a.startswith("-"):
            die("unknown flag: %s (tag words can't start with '-'; try sw help)" % a)
    tag = "-"
    if args:
        t = re.sub(r"[ \t\n\v\f\r]", "-", " ".join(args))
        t = re.sub(r"-{2,}", "-", t)
        if t.startswith("-"):
            t = t[1:]
        if t.endswith("-"):
            t = t[:-1]
        tag = t or "-"
    return tag


# ---- config (the section of wlog.md above `# log`) ----

_WS = r"[ \t\n\r\f\v]*"


def _read_bytes(path):
    with open(path, "rb") as f:
        return f.read()


def _lines(text):
    if not text:
        return []
    lines = text.split("\n")
    if text.endswith("\n"):
        lines.pop()
    return lines


def _config_section():
    text = _read_bytes(MD_LOG).decode("utf-8", "surrogateescape")
    section = []
    for line in _lines(text):
        if line == "# log":
            break
        section.append(line)
    return section


def read_config():
    if not os.path.isfile(MD_LOG):
        return
    try:
        section = _config_section()
    except OSError as e:
        die("config: cannot read %s: %s" % (MD_LOG, e.strerror))

    # First line whose whole value parses wins. A line that has the key but no
    # readable value is a hard error rather than a silent fallback to the default.
    def value(key, suffix):
        pat = re.compile(re.escape(key) + ":" + _WS + "([0-9]+)" + suffix + _WS)
        for line in section:
            m = pat.fullmatch(line)
            if m:
                return int(m.group(1))
        return None

    def present(key):
        return any(line.startswith(key + ":") for line in section)

    v = value("Daily target", "h?")
    if v is not None:
        if v < 1:
            die("config: 'Daily target' must be >= 1 (got %d)" % v)
        CFG.daily_target = v
    elif present("Daily target"):
        die("config: 'Daily target' line is present but its value is unreadable (expected an integer >= 1, optional 'h' suffix, and nothing after it; see 'sw help')")
    v = value("Working days per week", "")
    if v is not None:
        if not 1 <= v <= 7:
            die("config: 'Working days per week' must be 1-7 (got %d)" % v)
        CFG.working_days = v
    elif present("Working days per week"):
        die("config: 'Working days per week' line is present but its value is unreadable (expected an integer 1-7 and nothing after it; see 'sw help')")
    v = value("Pomodoro work", "m?")
    if v is not None:
        if v < 1:
            die("config: 'Pomodoro work' must be >= 1 (got %d)" % v)
        CFG.work_mins = v
    elif present("Pomodoro work"):
        die("config: 'Pomodoro work' line is present but its value is unreadable (expected an integer >= 1, optional 'm' suffix, and nothing after it; see 'sw help')")
    v = value("Pomodoro break", "m?")
    if v is not None:
        if v < 1:
            die("config: 'Pomodoro break' must be >= 1 (got %d)" % v)
        CFG.break_mins = v
    elif present("Pomodoro break"):
        die("config: 'Pomodoro break' line is present but its value is unreadable (expected an integer >= 1, optional 'm' suffix, and nothing after it; see 'sw help')")


# ---- the raw log ----
#
# One session per line: date, HH:MM (when it ended, which is when it was
# logged), seconds, tag, split on tabs or spaces. The reports read every line
# through parse_entry; last, amend and sub check only the last line's field
# count, so they can show or change a row the reports skip.

_DATE_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
_TIME_RE = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]")
_FIELD_SEP = re.compile(r"[ \t]+")

# Shorter sessions are accidental start/stops: no command writes one, and the
# reports leave out any found in an older or hand-edited raw log.
MIN_SESSION_SECS = 60


class Entry:
    __slots__ = ("date", "day", "hm", "secs", "mins", "tag")

    def __init__(self, date, day, hm, secs, tag):
        self.date = date
        self.day = day
        self.hm = hm
        self.secs = secs
        self.mins = round_mins(secs)
        self.tag = tag


def split_fields(line):
    return [f for f in _FIELD_SEP.split(line) if f]


def is_blank(line):
    return line.strip(" \t\r") == ""


def parse_entry(line):
    f = split_fields(line)
    if len(f) != 4:
        return None
    d, hm, s, tag = f
    if not (_DATE_RE.fullmatch(d) and _TIME_RE.fullmatch(hm) and _DIGITS.fullmatch(s)):
        return None
    try:
        day = datetime.date(int(d[0:4]), int(d[5:7]), int(d[8:10]))
    except ValueError:
        return None
    return Entry(d, day, hm, int(s), tag)


def read_raw_text():
    """The raw log as text, or None when it doesn't exist."""
    try:
        return _read_bytes(RAW_LOG).decode("utf-8", "surrogateescape")
    except FileNotFoundError:
        return None


def read_raw_or_die():
    try:
        return read_raw_text() or ""
    except OSError as e:
        die("cannot read %s" % _describe(e, RAW_LOG))


def load_entries(text):
    """Valid entries in file order, and (line number, text) for every non-blank
    line that isn't one."""
    entries, bad = [], []
    for n, line in enumerate(_lines(text or ""), 1):
        e = parse_entry(line)
        if e is not None:
            entries.append(e)
        elif not is_blank(line):
            bad.append((n, line))
    return entries, bad


# Only the reports filter: the clears count every entry, so a dry run and the
# real run agree on how many lines go.
def report_entries(entries):
    return [e for e in entries if e.secs >= MIN_SESSION_SECS]


def last_entry_index(lines):
    for i in range(len(lines) - 1, -1, -1):
        if not is_blank(lines[i]):
            return i
    return None


# ---- how a session reads (wlog.md and the terminal share these) ----


def _clock_mins(hm):
    return int(hm[0:2]) * 60 + int(hm[3:5])


def session_range(hm, mins):
    """`start–end` for a session logged at HH:MM, which is when it ended, that
    ran MINS minutes. One that began the evening before shows that evening's
    clock: 23:40–00:25."""
    start = (_clock_mins(hm) - mins) % 1440
    return "%02d:%02d–%s" % (start // 60, start % 60, hm)


# A tag literally named "untagged" reads the same as no tag, so it shares the
# untagged row instead of appearing twice under one name.
def _is_untagged(tag):
    return tag in ("-", "", "untagged")


def tag_label(tag):
    return "untagged" if _is_untagged(tag) else tag


def entry_dur(secs):
    """A logged duration as the reports show it, or to the second for a row
    too short for them to count."""
    return fmt_secs(secs) if secs < MIN_SESSION_SECS else fmt_mins(round_mins(secs))


def describe_session(date, hm, secs, tag):
    """A raw-log session as the terminal echoes it, in wlog.md's terms; a
    malformed time field is shown as it stands."""
    when = session_range(hm, round_mins(secs)) if _TIME_RE.fullmatch(hm) else hm
    return "%s %s %s %s" % (date, when, entry_dur(secs), tag_label(tag))


def day_total(date):
    """DATE's total in minutes, counted as wlog.md counts it."""
    try:
        entries, _ = load_entries(read_raw_text())
    except OSError:
        return 0
    return sum(e.mins for e in report_entries(entries) if e.date == date)


# "Today's total" when DATE is today, "Total for DATE" otherwise: amend and sub
# act on the last entry, which may belong to an earlier day, and a commit that
# straddles midnight lands on the day it was stamped.
def say_day_total(date):
    mins = day_total(date)
    if date == datetime.date.today().isoformat():
        say("Today's total: %s" % fmt_mins(mins))
    else:
        say("Total for %s: %s" % (date, fmt_mins(mins)))


# ---- writes ----


class LogUnchanged(Exception):
    """The raw log was left as it was. args[0] is the reason to print, or None
    when it has already been printed."""


class NoChange(Exception):
    """Nothing to change, so nothing to rebuild. args[0] is the caller's value."""


def _describe(e, path=""):
    if isinstance(e, OSError):
        where = e.filename or path
        return "%s: %s" % (where, e.strerror) if where else str(e.strerror)
    return "%s: %s" % (type(e).__name__, e)


def _write_all(fd, data):
    view = memoryview(data)
    while view:
        n = os.write(fd, view)
        view = view[n:]


# Temp file beside the target so the replace is a same-filesystem rename: a
# reader or a crash sees the old file or the new one, never a partial one.
def write_atomic(path, data):
    fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".", dir=os.path.dirname(path) or ".")
    try:
        try:
            _write_all(fd, data)
            os.fsync(fd)
        finally:
            os.close(fd)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


def append_row(row):
    """Append one row. Adds the newline a hand edit may have left off the last
    line, and on any write error cuts the file back to its prior size, so a
    failed append leaves no partial row behind."""
    try:
        d = os.path.dirname(RAW_LOG)
        if d:
            os.makedirs(d, exist_ok=True)
        fd = os.open(RAW_LOG, os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_CLOEXEC, 0o666)
    except OSError as e:
        raise LogUnchanged("cannot write %s" % _describe(e, RAW_LOG))
    try:
        size = os.fstat(fd).st_size
        data = row.encode("utf-8", "surrogateescape") + b"\n"
        if size and os.pread(fd, 1, size - 1) != b"\n":
            data = b"\n" + data
        try:
            _write_all(fd, data)
            os.fsync(fd)
        except OSError:
            try:
                os.ftruncate(fd, size)
            except OSError:
                pass
            raise
    except OSError as e:
        raise LogUnchanged("cannot write %s" % _describe(e, RAW_LOG))
    finally:
        os.close(fd)


def rewrite_raw(lines):
    data = "".join(line + "\n" for line in lines).encode("utf-8", "surrogateescape")
    try:
        write_atomic(RAW_LOG, data)
    except OSError as e:
        raise LogUnchanged("cannot write %s" % _describe(e, RAW_LOG))


def load_raw_lines():
    try:
        return _lines(read_raw_text() or "")
    except OSError as e:
        raise LogUnchanged("cannot read %s" % _describe(e, RAW_LOG))


# ---- lock ----


def acquire_lock():
    """Exclusive flock on LOCK_FILE, waiting up to 5 seconds. Returns the fd, or
    None after printing why."""
    try:
        if LOCK_DIR_IS_FALLBACK:
            os.makedirs(LOCK_DIR, mode=0o700, exist_ok=True)
        fd = os.open(LOCK_FILE, os.O_WRONLY | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600)
    except OSError as e:
        warn("cannot open lock %s" % _describe(e, LOCK_FILE))
        return None
    deadline = time.monotonic() + 5
    while True:
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            return fd
        except BlockingIOError:
            pass
        except OSError as e:
            os.close(fd)
            warn("cannot lock %s" % _describe(e, LOCK_FILE))
            return None
        if time.monotonic() >= deadline:
            os.close(fd)
            warn("another sw instance is running on this lock; try again in a moment")
            return None
        time.sleep(0.1)


_HELD_SIGNALS = {signal.SIGINT, signal.SIGHUP, signal.SIGTERM}


class held_signals:
    """Defers Ctrl+C, logout, and kill until the block ends, so no write is cut
    off halfway; a signal that arrived meanwhile takes effect right after."""

    def __enter__(self):
        self.old = signal.pthread_sigmask(signal.SIG_BLOCK, _HELD_SIGNALS)

    def __exit__(self, *exc):
        signal.pthread_sigmask(signal.SIG_SETMASK, self.old)
        return False


def locked_change(change):
    """Under the lock, apply change() to the raw log and rebuild wlog.md.

    change() returns a value for the caller or raises LogUnchanged. Returns
    (value, stale): stale is None when wlog.md was rebuilt, otherwise the reason
    it wasn't. The raw-log change stands either way, so the two failures are
    reported differently: a retry after a stale rebuild would log twice."""
    fd = acquire_lock()
    if fd is None:
        raise LogUnchanged(None)
    try:
        with held_signals():
            try:
                value = change()
            except NoChange as e:
                return e.args[0], None
            try:
                regenerate_md()
            except Exception as e:  # any rebuild failure leaves the raw-log change in place
                return value, _describe(e)
            return value, None
    finally:
        os.close(fd)


def run_change(change, fail_msg):
    try:
        value, stale = locked_change(change)
    except LogUnchanged as e:
        if e.args and e.args[0]:
            warn(e.args[0])
        die(fail_msg)
    if stale:
        warn("%s was updated, but %s wasn't rebuilt (%s); run: sw regen" % (RAW_LOG, MD_LOG, stale))
    return value


# ---- aggregates (whole minutes: every figure sums each entry's `mins`) ----


def summarize(entries):
    """Minutes logged, days worked, and the longest session, for ENTRIES."""
    total = longest = 0
    days = set()
    for e in entries:
        total += e.mins
        days.add(e.date)
        if e.mins > longest:
            longest = e.mins
    return total, len(days), longest


def in_range(entries, start, end):
    return [e for e in entries if start <= e.day <= end]


def per_day_worked(total, days):
    """TOTAL minutes averaged over DAYS worked, to the nearest minute."""
    return (2 * total + days) // (2 * days) if days else 0


def _whole_percents(values, total):
    """Whole percentages of TOTAL for VALUES, which sum to it, reconciled to
    100 by largest remainder; the earlier value wins a tie."""
    floors, rems = [], []
    for v in values:
        p = v * 100.0 / total
        floors.append(int(p))
        rems.append(p - int(p))
    for _ in range(100 - sum(floors)):
        i = max(range(len(rems)), key=lambda k: (rems[k], -k))
        floors[i] += 1
        rems[i] = -1
    return floors


def tag_rows(entries):
    """Every tag as (name, minutes, percent), most time first and the name
    breaking ties, with untagged time as an "untagged" row in its place. Each
    percent is rounded on its own, so equal times always show equal shares;
    the hours column is the exact one."""
    mins = {}
    for e in entries:
        name = tag_label(e.tag)
        mins[name] = mins.get(name, 0) + e.mins
    total = sum(mins.values())
    if total <= 0:
        return []
    names = sorted(mins, key=lambda t: (-mins[t], t))
    return [(n, mins[n], (200 * mins[n] + total) // (2 * total)) for n in names]


def top_tags(entries):
    """The top 3 tags plus "other" (the remaining tags and all untagged time),
    as whole percentages reconciled to 100. Empty when no tagged time falls in
    the range."""
    untagged = total = 0
    mins = {}
    for e in entries:
        if _is_untagged(e.tag):
            untagged += e.mins
        else:
            mins[e.tag] = mins.get(e.tag, 0) + e.mins
        total += e.mins
    if total <= untagged:
        return ""
    names = sorted(mins, key=lambda t: (-mins[t], t))
    buckets = [[n, mins[n]] for n in names[:3]]
    other = untagged + sum(mins[n] for n in names[3:])
    if other > 0:
        for b in buckets:
            if b[0] == "other":  # a real tag named "other" absorbs the residual
                b[1] += other
                break
        else:
            buckets.append(["other", other])
    percents = _whole_percents([b[1] for b in buckets], total)
    return ", ".join("%s %d%%" % (b[0], p) for b, p in zip(buckets, percents))


def best_records(entries):
    """All-time records as (minutes, when), or None where nothing is logged:
    the longest session (its day), and the best day, Mon-Sun week (its Monday),
    calendar month ((year, month)) and year. Periods still in progress count,
    as today does for the best day; the later period wins a tie, and the
    earlier session keeps the longest."""
    longest = None
    days, weeks, months, years = {}, {}, {}, {}
    for e in entries:
        if longest is None or e.mins > longest[0]:
            longest = (e.mins, e.day)
        monday = e.day - datetime.timedelta(days=e.day.weekday())
        for totals, key in ((days, e.day), (weeks, monday),
                            (months, (e.day.year, e.day.month)), (years, e.day.year)):
            totals[key] = totals.get(key, 0) + e.mins

    def best(totals):
        top = None
        for k, v in totals.items():
            if top is None or (v, k) > top:
                top = (v, k)
        return top

    return longest, best(days), best(weeks), best(months), best(years)


def count_working_days(start, end):
    """Days in [start, end] whose weekday (Mon=1) is <= the working days per week."""
    if start > end:
        return 0
    n = (end - start).days + 1
    full, rest = divmod(n, 7)
    count = full * CFG.working_days
    dow = start.isoweekday()
    for i in range(rest):
        if (dow - 1 + i) % 7 + 1 <= CFG.working_days:
            count += 1
    return count


def to_date_line(entries, period_start, period_end, first_ever, today):
    """The current period's line: its minutes against the target, and how many
    of its working days so far had any work. Both count from the later of the
    period's start and the first logged day, so the time before tracking began
    counts against neither."""
    start = max(period_start, first_ever)
    total = sum(e.mins for e in entries)
    target = CFG.daily_target * count_working_days(start, period_end)
    worked = len({e.day for e in entries
                  if start <= e.day <= today and e.day.isoweekday() <= CFG.working_days})
    line = "To date: %s" % fmt_mins(total)
    if target > 0:
        line += " of %dh target (%.1f%%)" % (target, total * 100.0 / (target * 60))
    return line + ", worked %d of %d working days" % (worked, count_working_days(start, today))


# ---- block renderers ----

FENCE = "```"


def _table(rows, right):
    """ROWS of text cells, aligned: columns three spaces apart, those indexed in
    RIGHT right-aligned, empty columns dropped, trailing blanks trimmed."""
    widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
    out = []
    for r in rows:
        cells = [c.rjust(w) if i in right else c.ljust(w)
                 for i, (c, w) in enumerate(zip(r, widths)) if w]
        out.append("   ".join(cells).rstrip())
    return out


def _day_label(d):
    return "%s %s %d" % (d.strftime("%a"), d.strftime("%b"), d.day)


def _dated(d):
    return "%s %s" % (d.strftime("%a"), d.isoformat())


def _week_label(monday):
    sunday = monday + datetime.timedelta(days=6)
    if monday.month == sunday.month:
        return "%s %d–%d" % (monday.strftime("%b"), monday.day, sunday.day)
    return "%s %d – %s %d" % (monday.strftime("%b"), monday.day, sunday.strftime("%b"), sunday.day)


def render_dashboard(entries, today):
    one = datetime.timedelta(days=1)
    yesterday = today - one
    week_start = today - datetime.timedelta(days=today.weekday())
    last_week_start = week_start - 7 * one

    def row(label, when, start, end, with_longest):
        ents = in_range(entries, start, end)
        total, _, longest = summarize(ents)
        lng = "longest %s" % fmt_mins(longest) if with_longest and longest else ""
        return [label, when, fmt_mins(total), lng, top_tags(ents)]

    # This week and last week are closed Mon-Sun ranges, matching the monthly
    # week lines. This week leaves out its longest session: still a moving target.
    rows = [row("Today", _day_label(today), today, today, True),
            row("Yesterday", _day_label(yesterday), yesterday, yesterday, True),
            row("This week", _week_label(week_start), week_start, week_start + 6 * one, False),
            row("Last week", _week_label(last_week_start), last_week_start, week_start - one, True)]
    out = [FENCE] + _table(rows, {2})
    longest, day, week, month, year = best_records(entries)
    best = []
    if longest:
        best.append(["Best session", fmt_mins(longest[0]), _dated(longest[1])])
    if day:
        best.append(["Best day", fmt_mins(day[0]), _dated(day[1])])
    if week:
        best.append(["Best week", fmt_mins(week[0]), "week of %s" % week[1].isoformat()])
    if month:
        best.append(["Best month", fmt_mins(month[0]), "%04d-%02d" % month[1]])
    if year:
        best.append(["Best year", fmt_mins(year[0]), "%04d" % year[1]])
    if best:
        out += [""] + _table(best, {1})
    out.append(FENCE)
    return "\n".join(out) + "\n"


_PERIOD_HEAD = ["Hours", "Days", "Avg/day", "Longest", "Top tag"]


def _period_cells(label, entries):
    """A period's table row: minutes, days worked, per day worked, longest
    session, top tag. One with nothing logged shows its zeros and no more."""
    if not entries:
        return [label, fmt_mins(0), "0", "", "", ""]
    total, days, longest = summarize(entries)
    top = next(("%s %d%%" % (n, p) for n, _, p in tag_rows(entries) if n != "untagged"), "")
    return [label, fmt_mins(total), str(days), fmt_mins(per_day_worked(total, days)),
            fmt_mins(longest), top]


def _period_table(first_head, rows):
    return _table([[first_head] + _PERIOD_HEAD] + rows, {1, 2, 3, 4})


def render_tag_table(entries):
    rows = [[n, fmt_mins(m), "%d%%" % p] for n, m, p in tag_rows(entries)]
    return _table([["Tag", "Hours", "%"]] + rows, {1, 2}) if rows else []


def render_years(entries, first_ever, today):
    """A row for every year since the first logged one, newest first, zeros
    included; empty until the span covers two years."""
    if first_ever is None:
        return ""
    last = max([today.year] + [e.day.year for e in entries])
    if last == first_ever.year:
        return ""
    by_year = {}
    for e in entries:
        by_year.setdefault(e.day.year, []).append(e)
    rows = [_period_cells("%04d" % y, by_year.get(y, [])) for y in range(last, first_ever.year - 1, -1)]
    return "\n".join([FENCE] + _period_table("Year", rows) + [FENCE]) + "\n"


def render_year(year, entries, first_ever, today):
    """The to-date line while it's the current year; a row for every month
    since tracking began, newest first, zeros included, the year's total last;
    then the year's tags."""
    first = first_ever.month if year == first_ever.year else 1
    if year < today.year:
        last = 12
    else:
        last = max([e.day.month for e in entries] + ([today.month] if year == today.year else []))
    by_month = {}
    for e in entries:
        by_month.setdefault(e.day.month, []).append(e)
    rows = [_period_cells(datetime.date(year, m, 1).strftime("%b"), by_month.get(m, []))
            for m in range(last, first - 1, -1)]
    rows.append(_period_cells("%04d" % year, entries))
    out = [FENCE]
    if year == today.year:
        out += [to_date_line(entries, datetime.date(year, 1, 1), datetime.date(year, 12, 31),
                             first_ever, today), ""]
    out += _period_table("Month", rows) + [""] + render_tag_table(entries) + [FENCE]
    return "\n".join(out) + "\n"


def _last_of_month(year, month):
    nxt = datetime.date(year + month // 12, month % 12 + 1, 1)
    return nxt - datetime.timedelta(days=1)


def render_month(year, month, entries, all_entries, first_ever, today):
    """The month's summary; while it's the current month, its to-date line and
    weeks; then its tags. Closed months show only what the raw log alone
    decides, so editing the config never rewrites them."""
    total, days, longest = summarize(entries)
    out = [FENCE, "%s · %d %s worked · %s per day worked · longest %s"
           % (fmt_mins(total), days, "day" if days == 1 else "days",
              fmt_mins(per_day_worked(total, days)), fmt_mins(longest))]
    if (year, month) == (today.year, today.month):
        out.append(to_date_line(entries, datetime.date(year, month, 1), _last_of_month(year, month),
                                first_ever, today))
        weeks = render_weeks_for_month(year, month, all_entries, today)
        if weeks:
            out += [""] + weeks
    out += [""] + render_tag_table(entries) + [FENCE]
    return "\n".join(out) + "\n"


def render_weeks_for_month(year, month, entries, today):
    """ISO weeks whose Monday falls in the month, latest first, skipping Mondays
    still ahead. Each shows its full Mon-Sun total, so a week that spills into
    the next month sits under the month it started in."""
    first = datetime.date(year, month, 1)
    last = _last_of_month(year, month)
    monday = first + datetime.timedelta(days=(7 - first.weekday()) % 7)
    mondays = []
    while monday <= last:
        mondays.append(monday)
        monday += datetime.timedelta(days=7)
    rows = []
    for mon in reversed(mondays):
        if mon > today:
            continue
        total = summarize(in_range(entries, mon, mon + datetime.timedelta(days=6)))[0]
        rows.append(["Week %02d" % mon.isocalendar()[1], _week_label(mon), fmt_mins(total)])
    return _table(rows, {2}) if rows else []


def _start_order(e):
    end = _clock_mins(e.hm)
    return (end - e.mins, end, e.secs, e.tag)


def render_days(entries):
    """Days latest first, each day's sessions in the order they started."""
    by_day = {}
    for e in entries:
        by_day.setdefault(e.day, []).append(e)
    width = max(len(fmt_mins(e.mins)) for e in entries)
    out = []
    for d in sorted(by_day, reverse=True):
        sessions = by_day[d]
        n = len(sessions)
        out.append("- %s — %s, %d %s" % (_dated(d), fmt_mins(sum(e.mins for e in sessions)), n,
                                         "session" if n == 1 else "sessions"))
        for e in sorted(sessions, key=_start_order):
            out.append("  - %s   %s   %s" % (session_range(e.hm, e.mins), fmt_mins(e.mins).rjust(width),
                                            tag_label(e.tag)))
    return "".join(line + "\n" for line in out)


def render_config_seed():
    """Written once, when wlog.md is first created: the effective config, which
    is the defaults at that point, fenced so a markdown preview keeps its lines
    apart. The suffixes are the ones read_config accepts, and it skips the
    fence lines, so the block reads back unchanged."""
    return ("# config (edit a value; delete a line to use its default)\n"
            + FENCE + "\n"
            + "%-22s %s\n" % ("Daily target:", "%dh" % CFG.daily_target)
            + "%-22s %s\n" % ("Working days per week:", "%d" % CFG.working_days)
            + "%-22s %s\n" % ("Pomodoro work:", "%dm" % CFG.work_mins)
            + "%-22s %s\n" % ("Pomodoro break:", "%dm" % CFG.break_mins)
            + FENCE + "\n\n")


_BAD_LINE_SHOWN = 5


def regenerate_md():
    """Rebuild wlog.md from the raw log, keeping everything above `# log`.
    Lines of the raw log that aren't sessions are named on stderr and left out;
    sessions under a minute are left out silently."""
    if not os.path.isfile(RAW_LOG):
        return
    entries, bad = load_entries(read_raw_text())
    for n, line in bad[:_BAD_LINE_SHOWN]:
        warn("warning: %s line %d isn't date, HH:MM, seconds, tag; left out of the totals: %s"
             % (RAW_LOG, n, line))
    if len(bad) > _BAD_LINE_SHOWN:
        warn("warning: %d more such lines in %s" % (len(bad) - _BAD_LINE_SHOWN, RAW_LOG))

    d = os.path.dirname(MD_LOG)
    if d:
        os.makedirs(d, exist_ok=True)
    # Keep everything above `# log`. A file with no `# log` line keeps all of it,
    # plus a separating blank line. Only a missing or empty file gets the seeded
    # config block, so a block the user deletes stays gone.
    if os.path.exists(MD_LOG) and os.path.getsize(MD_LOG) > 0:
        text = _read_bytes(MD_LOG).decode("utf-8", "surrogateescape")
        head, found = [], False
        for line in _lines(text):
            if line == "# log":
                found = True
                break
            head.append(line)
        header = "".join(line + "\n" for line in head) + ("" if found else "\n")
    else:
        header = render_config_seed()

    entries = report_entries(entries)
    today = datetime.date.today()
    first_ever = min((e.day for e in entries), default=None)
    by_year, by_month = {}, {}
    for e in entries:
        by_year.setdefault(e.day.year, []).append(e)
        by_month.setdefault((e.day.year, e.day.month), []).append(e)

    parts = [header, "# log\n\n", render_dashboard(entries, today)]
    years = render_years(entries, first_ever, today)
    if years:
        parts.append("\n## All years\n\n" + years)
    for y in sorted(by_year, reverse=True):
        parts.append("\n## %04d\n\n" % y)
        parts.append(render_year(y, by_year[y], first_ever, today))
        for (yy, m) in sorted((k for k in by_month if k[0] == y), reverse=True):
            parts.append("\n### %s\n\n" % datetime.date(yy, m, 1).strftime("%B"))
            parts.append(render_month(yy, m, by_month[(yy, m)], entries, first_ever, today))
            parts.append("\n")
            parts.append(render_days(by_month[(yy, m)]))
    write_atomic(MD_LOG, "".join(parts).encode("utf-8", "surrogateescape"))


# ---- live timer ----


class Timer:
    start = 0          # epoch the current timer began
    completed = False  # a countdown reached zero (never, for the count-up)
    elapsed = 0        # what the finished timer logs
    commit_tag = ""    # tag to log if logout or kill ends a committable phase; "" for the break
    signal_commit = False


TIMER = Timer()


def open_tty():
    """/dev/tty, which the timer reads for Enter. Checked before anything is
    printed: without a controlling terminal the timer can't run."""
    try:
        return os.open("/dev/tty", os.O_RDONLY | os.O_CLOEXEC)
    except OSError:
        die("sw needs a terminal: no readable /dev/tty, so the timer can't run")


def _exit_now(code):
    _write(sys.stdout, "")
    _write(sys.stderr, "")
    os._exit(code)


# Ctrl+C discards the running work session on purpose and says so. During a
# break there is nothing to discard: the pomodoro before it is already logged.
def _on_interrupt(signum, frame):
    if TIMER.commit_tag:
        _write(sys.stdout, "\n\n⏹ cancelled, nothing logged\n")
    else:
        _write(sys.stdout, "\n\n⏹ break cancelled\n")
    _exit_now(130)


def _commit_and_exit(code):
    """Logout, a terminal close, or kill during a committable phase: the session
    lives only in memory until committed, so commit what ran, then exit with the
    conventional 128+signal code."""
    if TIMER.signal_commit:
        return  # a second signal mid-commit: let the first one finish
    TIMER.signal_commit = True
    signal.pthread_sigmask(signal.SIG_BLOCK, _HELD_SIGNALS)
    elapsed = int(time.time()) - TIMER.start
    _write(sys.stderr, "\n\n⏹ terminated by signal\n")
    try:
        finish(elapsed, TIMER.commit_tag)
    except SystemExit as e:
        _exit_now(e.code if isinstance(e.code, int) else 1)
    _exit_now(code)


def _on_terminate(signum, frame):
    _commit_and_exit(129 if signum == signal.SIGHUP else 143)


def _enter_pressed(tty, timeout):
    """True once a line has been typed. Ctrl+D on a live terminal is ignored; a
    terminal that has gone away (read at EOF and no longer a working tty) is
    handled as the hangup it is, even where SIGHUP never arrives."""
    ready, _, _ = select.select([tty], [], [], timeout)
    if not ready:
        return False
    try:
        data = os.read(tty, 4096)
    except OSError:
        data = b""
    if data:
        return True
    try:
        termios.tcgetattr(tty)
        return False
    except termios.error:
        pass
    if TIMER.commit_tag:
        _commit_and_exit(129)
    _exit_now(129)


# One-second live timer shared by the stopwatch, the pomodoro work phase, and
# the break, so their displays are identical by construction. Both modes count
# from wall-clock time, so a suspend or a closed lid counts as elapsed instead
# of pausing the timer. A countdown that reaches zero logs its nominal length;
# an early Enter and the count-up log the wall-clock span actually run. Each
# redraw ends with EL (\033[K) so a clock that shrinks at the hour boundary
# (1:00:00 -> 59:59) leaves no stale columns.
def run_timer(tty, mode, icon, label, total=0, commit_tag=""):
    TIMER.start = int(time.time())
    TIMER.completed = False
    TIMER.commit_tag = commit_tag
    TIMER.signal_commit = False
    signal.signal(signal.SIGINT, _on_interrupt)
    if commit_tag:
        signal.signal(signal.SIGHUP, _on_terminate)
        signal.signal(signal.SIGTERM, _on_terminate)
    try:
        while True:
            now = int(time.time())
            if mode == "down":
                remaining = max(0, total - (now - TIMER.start))
                _write(sys.stdout, "\r   %s  %s: %s \033[K" % (icon, label, fmt_clock(remaining)))
                if remaining == 0:
                    TIMER.completed = True
                    break
            else:
                _write(sys.stdout, "\r   %s  %s: %s \033[K" % (icon, label, fmt_clock(now - TIMER.start)))
            if _enter_pressed(tty, 1.0):
                break
    finally:
        for s in (signal.SIGINT, signal.SIGHUP, signal.SIGTERM):
            signal.signal(s, signal.SIG_DFL)
    TIMER.elapsed = total if TIMER.completed else int(time.time()) - TIMER.start


def _hm_in(seconds):
    return time.strftime("%H:%M", time.localtime(time.time() + seconds))


def _run_quietly(cmd, **kw):
    try:
        subprocess.run(cmd, check=False, **kw)
    except OSError:
        pass


def play_sound(path):
    if shutil.which("paplay"):
        _run_quietly(["paplay", path], stderr=subprocess.DEVNULL)


# ---- modes ----


def finish(elapsed, tag):
    """Commit a finished timer session. If it can't be written, say so with the
    command that puts it back, since the session exists nowhere else."""
    if elapsed < MIN_SESSION_SECS:
        say("under a minute, not logged")
        return
    stamp = time.localtime()  # one reading: the row's date and the total line agree
    day = time.strftime("%Y-%m-%d", stamp)
    hm = time.strftime("%H:%M", stamp)
    recover = "sw add %ds%s" % (elapsed, "" if tag == "-" else " " + tag)
    run_change(lambda: append_row("%s\t%s\t%d\t%s" % (day, hm, elapsed, tag)),
               "failed to commit session; recover it with: %s" % recover)
    say("Session: %s" % fmt_mins(round_mins(elapsed)))
    say_day_total(day)


def run_stopwatch(tag):
    tty = open_tty()
    say("⏱  stopwatch started %s. Enter stops & logs, Ctrl+C cancels." % time.strftime("%H:%M"))
    say("")
    run_timer(tty, "up", "⏱", "elapsed", 0, tag)
    say("")
    say("")
    say("⏹ stopped")
    finish(TIMER.elapsed, tag)


def run_pomodoro(tag):
    tty = open_tty()
    mins = CFG.work_mins
    say("🍅 pomodoro started! focus for %d minutes." % mins)
    say("   Started %s, ends %s. Enter stops early & logs, Ctrl+C cancels."
        % (time.strftime("%H:%M"), _hm_in(mins * 60)))
    say("")
    run_timer(tty, "down", "⏱", "time remaining", mins * 60, tag)
    say("")
    say("")
    if TIMER.completed:
        say("✅ session complete!")
        finish(TIMER.elapsed, tag)
        play_sound(SOUND_SESSION_END)
        say("")
        run_break(tty, CFG.break_mins)
    else:
        say("⏹ stopped early")
        finish(TIMER.elapsed, tag)


# Rest timer shared by the pomodoro and `sw break`. Logs nothing.
def run_break(tty, mins):
    say("%dm break. Press Enter to skip." % mins)
    say("   Break ends %s" % _hm_in(mins * 60))
    say("")
    run_timer(tty, "down", "☕", "break remaining", mins * 60)
    say("")
    say("")
    if TIMER.completed:
        say("⏰ break over!")
        play_sound(SOUND_BREAK_END)
        if shutil.which("notify-send"):
            _run_quietly(["notify-send", "⏰ break over", "back to work"])
    else:
        say("⏭ break skipped")


def cmd_break(args):
    mins, got = CFG.break_mins, False
    for a in args:
        if a.startswith("-"):
            die("break: unknown flag: %s" % a)
        if got:
            die("break: only one minutes value allowed (got extra '%s')" % a)
        m = re.fullmatch(r"([0-9]+)m?", a)
        if not m:
            die("break: minutes must be a positive integer (got '%s')" % a)
        if int(m.group(1)) < 1:
            die("break: minutes must be >= 1")
        mins, got = int(m.group(1)), True
    run_break(open_tty(), mins)


def cmd_sum(args):
    if args:
        die("sum: takes no arguments")
    today = datetime.date.today().isoformat()
    total = count = 0
    if os.path.isfile(RAW_LOG):
        for e in report_entries(load_entries(read_raw_or_die())[0]):
            if e.date == today:
                total += e.mins
                if e.mins >= CFG.work_mins:
                    count += 1
    say("Today's total: %s (%d work-length sessions)" % (fmt_mins(total), count))


def cmd_last(args):
    if args:
        die("last: takes no arguments")
    if not os.path.isfile(RAW_LOG):
        say("no sessions logged")
        return
    lines = _lines(read_raw_or_die())
    i = last_entry_index(lines)
    if i is None:
        say("no sessions logged")
        return
    last = lines[i]
    f = split_fields(last)
    if len(f) != 4:
        die("last entry isn't in the expected 4-field format ('%s'); edit %s" % (last, RAW_LOG))
    d, t, s, tg = f
    if not _DIGITS.fullmatch(s):
        die("last entry has a malformed seconds field ('%s'); edit %s" % (s, RAW_LOG))
    say(describe_session(d, t, int(s), tg))


# Unlike the timer paths, where a missing player or file stays silent, the
# point of `sw sound` is diagnosis, so it reports what's wrong.
def cmd_sound(args):
    if args:
        die("sound: takes no arguments")
    if not shutil.which("paplay"):
        die("paplay not found (Devuan: apt install pulseaudio-utils)")
    say("pomo session-end sound:")
    if os.path.isfile(SOUND_SESSION_END):
        play_sound(SOUND_SESSION_END)
    else:
        say("missing: %s" % SOUND_SESSION_END)
    time.sleep(1)
    say("break-end sound:")
    if os.path.isfile(SOUND_BREAK_END):
        play_sound(SOUND_BREAK_END)
    else:
        say("missing: %s" % SOUND_BREAK_END)


# ---- mutating commands ----


def _has_entries():
    return os.path.isfile(RAW_LOG) and os.path.getsize(RAW_LOG) > 0


def cmd_add(args):
    if not args:
        die("usage: sw add DURATION [tag]   (e.g. 90m, 1h30m, 5400s)")
    secs = parse_duration(args[0])
    if secs is None:
        die("invalid duration: %s (use 90m, 1h30m, 5400s, or a bare integer = minutes)" % args[0])
    if secs < MIN_SESSION_SECS:
        die("too short: %s (sessions under a minute aren't logged)" % args[0])
    tag = args_to_tag(args[1:])
    # Stamped now, like the live timer: column 2 means "when the session ended
    # or was logged" for every row, whatever wrote it.
    stamp = time.localtime()
    log_date = time.strftime("%Y-%m-%d", stamp)
    log_hm = time.strftime("%H:%M", stamp)
    run_change(lambda: append_row("%s\t%s\t%d\t%s" % (log_date, log_hm, secs, tag)), "failed to add session")
    say("added: %s" % describe_session(log_date, log_hm, secs, tag))
    say_day_total(log_date)


# The last entry is read, checked, and rewritten entirely under the lock:
# reading it outside would let a concurrent commit land in between and be lost.
def cmd_amend(args):
    if len(args) != 1:
        die("usage: sw amend DURATION   (rewrites the last entry's duration)")
    if not _has_entries():
        die("no entries to amend")
    secs = parse_duration(args[0])
    if secs is None:
        die("invalid duration: %s" % args[0])
    if secs < MIN_SESSION_SECS:
        die("too short: %s (sessions under a minute aren't logged)" % args[0])

    def change():
        lines = load_raw_lines()
        i = last_entry_index(lines)
        if i is None:
            raise LogUnchanged("no entries to amend")
        f = split_fields(lines[i])
        if len(f) != 4:
            raise LogUnchanged("last entry isn't in the expected 4-field format; edit %s directly" % RAW_LOG)
        rewrite_raw(lines[:i] + ["%s\t%s\t%d\t%s" % (f[0], f[1], secs, f[3])])
        return f

    f = run_change(change, "failed to amend session")
    old = entry_dur(int(f[2])) if _DIGITS.fullmatch(f[2]) else "'%s'" % f[2]
    say("amended: %s (was %s)" % (describe_session(f[0], f[1], secs, f[3]), old))
    say_day_total(f[0])


def cmd_sub(args):
    if not _has_entries():
        die("no entries to remove")
    if len(args) > 1:
        die("usage: sw sub [DURATION]   (no arg removes the last entry; DURATION reduces it)")
    if not args:
        # Removes the last entry and echoes it, so a mistaken delete can be re-added.
        def delete():
            lines = load_raw_lines()
            i = last_entry_index(lines)
            if i is None:
                raise LogUnchanged("no entries to remove")
            rewrite_raw(lines[:i])
            return lines[i]

        removed = run_change(delete, "failed to remove entry")
        f = split_fields(removed)
        if len(f) == 4 and _DIGITS.fullmatch(f[2]):
            say("removed: %s" % describe_session(f[0], f[1], int(f[2]), f[3]))
            say_day_total(f[0])
        else:
            say("removed: %s" % removed)
        return
    delta = parse_duration(args[0])
    if delta is None:
        die("invalid duration: %s (use 90m, 1h30m, 5400s, or a bare integer = minutes)" % args[0])

    # Refuses to leave less than a minute: removing the entry is bare `sw sub`'s
    # job, not a side effect of over-subtracting.
    def reduce():
        lines = load_raw_lines()
        i = last_entry_index(lines)
        if i is None:
            raise LogUnchanged("no entries to subtract from")
        f = split_fields(lines[i])
        if len(f) != 4:
            raise LogUnchanged("last entry isn't in the expected 4-field format; edit %s directly" % RAW_LOG)
        if not _DIGITS.fullmatch(f[2]):
            raise LogUnchanged("last entry has a malformed seconds field ('%s'); edit %s" % (f[2], RAW_LOG))
        cur = int(f[2])
        new = cur - delta
        if new < MIN_SESSION_SECS:
            raise LogUnchanged("cannot subtract %s from a %s entry: that would leave under a minute "
                               "(use bare 'sw sub' to remove the entry)" % (fmt_secs(delta), entry_dur(cur)))
        rewrite_raw(lines[:i] + ["%s\t%s\t%d\t%s" % (f[0], f[1], new, f[3])])
        return f, new

    f, new = run_change(reduce, "failed to subtract")
    say("subtracted %s: %s" % (fmt_secs(delta), describe_session(f[0], f[1], new, f[3])))
    say_day_total(f[0])


def _parse_yes_flags(args, cmd, positional):
    apply, values = False, []
    for a in args:
        if a in ("-y", "--yes"):
            apply = True
        elif a.startswith("-"):
            die("%s: unknown flag: %s" % (cmd, a))
        else:
            positional(values, a)
    return apply, values


def cmd_clear(args):
    def reject(values, a):
        die("clear: unexpected argument: %s" % a)

    apply, _ = _parse_yes_flags(args, "clear", reject)
    if not _has_entries():
        say("no log to clear (%s is empty or missing)" % RAW_LOG)
        return
    if not apply:
        count = read_raw_or_die().count("\n")
        say("would clear all %d lines from %s" % (count, RAW_LOG))
        say("re-run with -y to commit: sw clear -y")
        return

    # A file-level wipe: counts lines, junk included, under the lock.
    def wipe():
        try:
            count = _read_bytes(RAW_LOG).count(b"\n")
            fd = os.open(RAW_LOG, os.O_WRONLY | os.O_CLOEXEC)
            try:
                os.ftruncate(fd, 0)
                os.fsync(fd)
            finally:
                os.close(fd)
        except OSError as e:
            raise LogUnchanged("cannot write %s" % _describe(e, RAW_LOG))
        return count

    count = run_change(wipe, "failed to clear log")
    say("cleared %d lines from %s" % (count, RAW_LOG))


def _clear_before_counts(entries, cutoff):
    return len(entries), sum(1 for e in entries if e.date >= cutoff)


def cmd_clear_before(args):
    def one_date(values, a):
        if values:
            die("clear-before: only one date allowed (got '%s' and '%s')" % (values[0], a))
        values.append(a)

    apply, values = _parse_yes_flags(args, "clear-before", one_date)
    if not values:
        die("usage: sw clear-before DATE [-y]   (DATE: YYYY-MM-DD or YYYYMMDD)")
    cutoff = normalize_date(values[0])
    if cutoff is None:
        die("invalid date: %s (use YYYY-MM-DD or YYYYMMDD)" % values[0])
    if not _has_entries():
        say("no log to clear (%s is empty or missing)" % RAW_LOG)
        return
    if not apply:
        total, keep = _clear_before_counts(load_entries(read_raw_or_die())[0], cutoff)
        if total == keep:
            say("no entries before %s; nothing to do" % cutoff)
            return
        say("would delete %d entries before %s (keeping %d)" % (total - keep, cutoff, keep))
        say("re-run with -y to commit: sw clear-before %s -y" % cutoff)
        return

    # Counted under the lock, so the number reported is the number removed.
    # Lines that aren't entries pass through: the contract is "delete entries
    # before DATE", and a stray hand-edited line is not an entry.
    def prune():
        lines = load_raw_lines()
        kept, total, keep = [], 0, 0
        for line in lines:
            e = parse_entry(line)
            if e is None:
                kept.append(line)
                continue
            total += 1
            if e.date >= cutoff:
                keep += 1
                kept.append(line)
        if total == keep:
            raise NoChange(0)
        rewrite_raw(kept)
        return total - keep

    deleted = run_change(prune, "failed to clear log")
    if deleted == 0:
        say("no entries before %s; nothing to do" % cutoff)
    else:
        say("deleted %d entries before %s" % (deleted, cutoff))


def cmd_regen(args):
    if args:
        die("regen: takes no arguments")
    if not os.path.isfile(RAW_LOG):
        say("no raw log to regenerate from (%s is missing)" % RAW_LOG)
        return
    fd = acquire_lock()
    if fd is None:
        die("failed to regenerate %s" % MD_LOG)
    try:
        with held_signals():
            regenerate_md()
    except Exception as e:
        warn(_describe(e, MD_LOG))
        die("failed to regenerate %s" % MD_LOG)
    finally:
        os.close(fd)
    say("regenerated %s from %s" % (MD_LOG, RAW_LOG))


# ---- help and version ----


def show_help():
    _write(sys.stdout, HELP_TEXT)


def version_line():
    """Line 2 of this file (`# sw, vN`) without the `# `: the single source for
    the version, so the header and `sw version` can't drift."""
    try:
        with open(os.path.realpath(__file__), "rb") as f:
            f.readline()
            line = f.readline().decode("utf-8", "replace").rstrip("\n")
    except OSError:
        return ""
    return line[2:] if line.startswith("# ") else ""


# ---- dispatch ----


def _init_paths():
    global RAW_LOG, MD_LOG, LOCK_DIR, LOCK_FILE, LOCK_DIR_IS_FALLBACK
    global SOUND_SESSION_END, SOUND_BREAK_END
    home = os.environ.get("HOME")
    if home is None:
        die("HOME is not set")

    def env(name, default):
        return os.environ.get(name) or default

    RAW_LOG = env("SW_RAW_LOG", home + "/.stopwatch_log")
    MD_LOG = env("SW_MD_LOG", home + "/Documents/mv/wlog.md")
    SOUND_SESSION_END = env("SW_SOUND_SESSION_END", DEFAULT_SOUND_SESSION_END)
    SOUND_BREAK_END = env("SW_SOUND_BREAK_END", DEFAULT_SOUND_BREAK_END)
    # Never /tmp: a lock file another account can create first would block
    # every commit. Only this user can write under ~/.cache.
    runtime = os.environ.get("XDG_RUNTIME_DIR")
    LOCK_DIR_IS_FALLBACK = not runtime
    LOCK_DIR = runtime or home + "/.cache"
    LOCK_FILE = "%s/sw-%d.lock" % (LOCK_DIR, os.getuid())


def main(argv):
    # Ctrl+C outside a timer, and a closed pipe, end the program quietly as they
    # would a shell tool, instead of raising a Python traceback.
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
    for stream in (sys.stdout, sys.stderr):
        if stream is not None:
            stream.reconfigure(encoding="utf-8", errors="surrogateescape")
    try:
        locale.setlocale(locale.LC_TIME, "")  # month names follow LC_TIME, as date(1) does
    except locale.Error:
        pass
    _init_paths()

    first = argv[0] if argv else ""
    # help and version work even when the config is broken, so you can read how to fix it.
    if first in ("help", "-h", "--help"):
        show_help()
        return 0
    if first in ("version", "--version"):
        say(version_line())
        return 0

    read_config()

    commands = {
        "sound": cmd_sound, "last": cmd_last, "sum": cmd_sum, "add": cmd_add,
        "amend": cmd_amend, "sub": cmd_sub, "clear": cmd_clear,
        "clear-before": cmd_clear_before, "regen": cmd_regen, "break": cmd_break,
    }
    if first == "--":
        # `--` ends subcommand matching, so tags named like subcommands stay reachable.
        run_stopwatch(args_to_tag(argv[1:]))
    elif first in commands:
        commands[first](argv[1:])
    elif first == "pomo":
        run_pomodoro(args_to_tag(argv[1:]))
    elif first.startswith("-"):
        die("unknown flag: %s (try sw help)" % first)
    else:
        # Default: stopwatch, every argument a tag word.
        run_stopwatch(args_to_tag(argv))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Contribute

We’re stronger together. Please contribute. Every contribution, no matter how small, is valuable.

Beginners on Github, just create a issue. Please provide as much detail as possible about the change or update required.

Technical users, please fork the project and submit a PR.

Beginners who want to learn how technical users submit a PR, see this comprehensive guide by Bitlevi which provides all the necessary steps and tools you will need to confidently contribute.

About

Equipping students, writers, and other knowledge workers with tools of power and productivity

Docwright

As an early 20th–century author on craftsmanship said about wheelwrights, they “were friends, as only a craftsman can be, with timber and iron. The grain of wood told secrets to them” (George Stuart, Wheelwright’s Shop, 1923). I am a technical writer, a documentation engineer if you will, an aspiring docwright. I have 10+ years of experience in tech startups. I write about complex topics for people outside the field. Some of the guides here explain invaluable concepts I’ve learnt along the way, made “as simple as possible, but not simpler” (a paraphrase of Albert Einstein). You’ll find in here topics directly or indirectly related to writing, mostly the latter: things I wish I’d known before I started my career.

Motivation

The core problem

Think of a book that changed your mind: not the one you enjoyed, but one that moved you to disavow a position you once held. Now find your notes on it: not the book, the notes, the objection you scrawled in the margin, the passage you copied out, the thought you had and scribbled at dawn’s first light. Good if you found them. Most do not even take notes. And many who do, for various reasons, cannot find them when needed. A lot of labour was wasted. And that, essentially, is what this site attempts to address, by helping people with tools that increase their productivity and security.

If you are going to spend years learning (reading, studying, writing, researching, making notes, developing ideas, cultivating a body of work, a garden, if you will), you need tools, weapons even, to keep it. What’s the point of building something you cannot keep and, more importantly, pass down to your children?

With great power comes great responsibility

Our fathers had nothing like the tools now available to us for free. Yet very few ever learn to own the ground their intellectual life stands on. Files live in somebody else’s cloud. Notes are scattered across notebooks, PDFs, Word documents, LLM platforms, browser tabs, and forgotten folders. Research is spread across a dozen applications that do not speak to each other. Private material sits on someone else’s servers, under someone else’s terms. Backups are thin, untested, or absent. Identity rests on passwords held by companies. And publishing waits on someone else’s permission. But we’re in the 21st century, and you have no excuse. It’s never been easier to be a literary person, and it’s never been easier to spin up a blog or online book and share with others what you’ve learnt, all for free.

Bridging the gap

So many students and successful writers have no clue about the tools in the tech world that significantly boost productivity and privacy. Such tools have been around for a while, but often the documentation to bridge the gap between the developers and the laymen is lacking, or it is written hurriedly by developers for developers.

Often, all that is needed is for information to be brought to light. Then there are times when what is needed is for things to be said more clearly; “everything that can be said can be said clearly” (Ludwig Wittgenstein, Tractatus Logico-Philosophicus, 4.116). Other times, and more often than not, what is needed is for information to be arranged in the order of importance. As finite creatures, there’s only so much time we have. And when you get down to it, there’s really not a lot one needs to know to complete a task, achieve a goal, or attain significant success. Put first things first and second things are thrown in; put second things first and you lose both first and second things, as C. S. Lewis argued in a 1951 letter.

In short, many problems can be solved by simply bringing the truth to light, expressing it more clearly, and ordering it more rightly. Enter docwrights. Bridging the gap between subject-matter experts and the rest of us is essentially what technical writers, or writers in general, concern themselves with: crafting sentences and presenting them rightly and tastefully, to help themselves and others get things done and make the world a better place.

Hopefully Docwright becomes a good example of how to document things.

The tools are not the garden

The goal is not to become a sysadmin. It’s not to collect technical skills so you can show off on your résumé. The goal is wisdom, understanding, a life worth living, maybe passing an inheritance to the next generation. As you pursue those goals, Docwright teaches you about tools that will increase your productivity and power, yet the tools are not the garden.

A request

I’m not a dev, at least not yet. All the code here is written with Anthropic’s Opus or Fable models. My coding skills is illustrated in the video below.

So, dear devs and security experts, could you please help us laymen out: if you spot a bug or a bad idea in any script, open an issue or send a fix.

“We reject: kings, presidents and voting. We believe in: rough consensus and running code.” (David Clark, IETF, July 1992)

Contact

You can write to me at jake.s47@atomicmail.io.