Per-user and per-team storage limits need the kernel, not the platform. Members hold terminal at write and have their own Linux accounts, so a quota counted through the file browser is decorative — they open a shell and write past it. The plan is usrquota and grpquota, deliberately not project quotas: every case here is "this user" or "this team", both already identities the kernel knows, and a team is already a Linux group. Skipping prjquota also skips the inode-size trap, since project quotas need 256-byte inodes and cannot be added to a filesystem that has 128. Includes the pre-flight that decides whether the procedure applies at all, the rescue-boot steps, and a throwaway-account test that proves enforcement — because enabling quotas and setting none looks exactly like success. The easiest thing to get wrong is recorded prominently: a team folder that is not setgid charges its files to each member's own group, so the team limit counts nothing. For edge-pertento, which is the first real multi-user host and already has one account. officer-dev will not be multi-user and alpha is the owner's own; neither needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
6.5 KiB
Filesystem quotas — the prerequisite for per-user and per-team storage limits
Status: planned, not done. Nothing in the platform reads or writes a quota yet.
This is the host-side half. It makes limits enforceable; it does not create any. The platform side —
the owner declaring defaults and maxima, and provisioning applying them — is docs/roles-and-teams.md
→ "Quotas", and it is not built either. Do this first, because a policy nothing enforces is worse than
no policy: it reads as a limit and is not one.
Why the kernel and not the platform
Members hold terminal at write and have their own Linux accounts. A quota the platform tracks by
counting bytes through the file browser is decorative — a member opens a terminal, or connects over SSH
with their own key, and writes past it without the platform seeing the write.
Only the filesystem can refuse. So this is a provisioning feature, not an accounting one.
Where it matters
edge-pertento. It is the first real multi-user deployment and the only host that will hold accounts
that are not the owner's. It already has one account, which is the argument for doing this soon rather
than carefully later: moving a home directory is easy once and tedious per person.
officer-dev is a development machine and will not be multi-user. alpha is the owner's own. Neither
needs this, and neither can do it without the same downtime, so there is no reason to.
What to check BEFORE booting into rescue
Run these on the live machine. They decide whether the procedure below is the right one.
findmnt -no FSTYPE,SOURCE,TARGET / # expect ext4, and note the real device
findmnt -no OPTIONS / | tr ',' '\n' | grep -i quota # expect nothing — that is why we are here
sudo tune2fs -l "$(findmnt -no SOURCE /)" | grep -iE 'features|Inode size'
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT
df -h /home
Three things decide the plan:
- Is
/homeits own filesystem, or part of/? On a stock Hetzner image it is part of/, so the root filesystem is what gains the feature. - Inode size. Only matters if you use project quotas — they need 256 bytes and cannot be enabled on a 128-byte filesystem without recreating it. The plan below avoids project quotas for exactly this reason.
- Is the device under LVM?
alphais;officer-devis not. It changes only the device path.
The plan: user quotas and group quotas, not project quotas
Two mount options cover both cases and neither needs a feature the filesystem might lack:
usrquota— a limit per Linux user. This is the per-account home directory limit.grpquota— a limit per Linux group. This is how a team's shared folder gets a limit, because a team is already a Linux group (seedocs/roles-and-teams.md— the group is what keeps one team out of another's folder, not the permission grant).
Project quotas (prjquota) would let a directory carry a limit independent of ownership. That is more
flexible and it is not needed: every case here is "this user" or "this team", both of which are already
identities the kernel knows. Skipping it also skips the inode-size problem entirely.
For the group quota to work, the team folder must be setgid (chmod g+s), so files created inside it
inherit the team's group and are charged to it. Without that, a member's files are charged to the member's
own primary group and the team limit counts nothing. This is the single easiest thing to get wrong here.
Procedure
Downtime is roughly ten minutes, most of it reboots. The owner has said downtime is acceptable.
1. Install the tools, on the live machine.
sudo apt-get update && sudo apt-get install -y quota
2. Take note of the root device, from the pre-flight above — /dev/sda1 on a stock image,
/dev/mapper/<vg>-<lv> under LVM. Every command below uses it.
3. Boot into Hetzner rescue from the Cloud console, and do not mount the filesystem.
4. Check it, then add the feature. tune2fs refuses on a filesystem that is not clean, and the check
is worth running anyway before changing metadata:
e2fsck -f /dev/sda1
tune2fs -O quota /dev/sda1
tune2fs -l /dev/sda1 | grep -i features # 'quota' must now appear
quota here is the ext4 journaled quota feature. It is what lets quotas come up with the filesystem
rather than needing a quotacheck scan at every boot, which on 320 GB is not quick.
5. Add the mount options. Back on the normal system — or by mounting the root filesystem from rescue
and editing it there — the / line in /etc/fstab gains usrquota,grpquota:
UUID=<unchanged> / ext4 defaults,usrquota,grpquota 0 1
6. Reboot into the normal system and confirm.
findmnt -no OPTIONS / | tr ',' '\n' | grep quota # usrquota, grpquota
sudo quotaon -p / # both should report "is on"
sudo repquota -s / # a table, not an error
Verify it actually refuses
Enabling quotas and setting none is the failure that looks like success. Prove enforcement once, on a throwaway account, before trusting it:
sudo useradd -m quotaprobe
sudo setquota -u quotaprobe 10240 10240 0 0 / # 10 MB soft and hard
sudo -u quotaprobe dd if=/dev/zero of=/home/quotaprobe/fill bs=1M count=50
# expect: "Disk quota exceeded" and a file of about 10 MB, NOT 50
sudo userdel -r quotaprobe
If that writes 50 MB, quotas are not on, whatever the mount options say.
What this does not do
- It sets no limits. Every existing account is unlimited until something assigns a quota. That is the correct intermediate state: the machine gains the ability, nobody's behaviour changes.
- It does not touch the platform.
provision-os.tsdoes not callsetquotayet, and there is nowhere for the owner to declare a default. That is the other half. - It does not apply to the team folders, because there are no team folders yet. When they arrive they
must be
setgidor their group quota counts nothing.
When the platform half is built
POST /users/:id/provision-linux already exists as a retry path and is the natural home for this: it
becomes "re-apply current policy to this account", covering both a new account and a changed limit.
On a host where quotas are not enabled, the platform must say so — "policy recorded, not enforceable on
this host" — rather than storing numbers that do nothing. Same rule as the plugin dependency check: refuse
or report, never silently no-op. quotaon -p is the check, and it is cheap enough to run per request.