Files
ubuntu-post-install/docs/vps-sizing-recommendations.md
T
Claude 2fb2a2980d Document the 6vCPU/8GB Tier 3 sizing plan
Settled stack: 3x Mattermost, 1x Traccar (down from 2x to buy back RAM),
2x each of ntfy/mealie/wordpress/actualbudget/audiobookshelf/emby
(music-only + everything)/filebrowser/fmd/homebox/joplin/rustdesk/
vaultwarden, 1x each of asterisk/security-dashboard/sms-inbound (all
three are singleton-by-design, no multi-instance support exists for
them). changedetection and magicmirror x6 dropped — the former never got
the full multi-instance retrofit, the latter's existing pattern caps at
3 instances.

Comes out to ~6.0GB of 8GB (~25% headroom) with RustDesk's relay for
screen sharing, or ~6.4GB (~20% headroom) with MeshCentral instead.
2026-08-10 00:09:49 +00:00

325 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VPS sizing & recommended services
Reference notes from sizing this repo's services against real VPS plans.
Not project documentation for contributors — a planning record for picking
services against a given vCPU/RAM/disk budget.
## How to size a VPS for this repo's services
For this class of self-hosted workload (web apps, a PBX, a few Docker
containers — no video transcoding, no ML inference), **RAM is almost always
the binding constraint, not CPU or disk.** CPU only matters once something is
transcoding video, mixing many conference audio streams, or running local
AI inference — none of which apply to most services in this repo. Disk only
matters once local media storage is involved.
Rough per-service RAM budget, idle:
| Kind of service | ~RAM |
|---|---|
| OS + Docker daemon baseline | 300-500MB |
| JVM apps (Traccar, UniFi) | 350-500MB |
| App with its own Postgres/MariaDB (Mattermost, Nextcloud, Immich) | +150-250MB for the DB alone, on top of the app |
| Lightweight single-binary apps (Go/Rust — ntfy, vaultwarden, wg-easy, homebox, actualbudget, syncthing, portainer, coturn) | 20-150MB each |
| Headless-Chrome-backed apps (archivebox, changedetection's JS mode) | 300-500MB+ |
Rules of thumb:
- Keep at least 25-30% of total RAM free at idle for burst load (image
pulls, log bursts, concurrent call/session spikes).
- A swapfile is cheap insurance and is now a **default for every install**,
not just Asterisk droplets — `base.sh` calls `lib/common.sh`'s
`ensure_swapfile()` unconditionally, which offers a 2GB swapfile any time
RAM is ≤4096MB and none exists yet (`services/asterisk.sh` also calls it
directly for the standalone-run case, so it's covered either way).
- Sharing one `coturn` instance (`services/coturn.sh`) instead of letting
each WebRTC-capable service (Asterisk, Mattermost) embed its own saves a
container per consumer and — more importantly — avoids relay-port
collisions between them.
## Tier 1 — ~1 vCPU / 1GB RAM / 25GB SSD
Example: DigitalOcean Basic, $6/mo.
This is tight enough that Docker's own daemon overhead is already a
meaningful fraction of the box. **Pick one purpose, not a stack:**
- **Option A — Asterisk only.** Asterisk + the shared coturn service fits
comfortably per this repo's own droplet-sizing notes (`services/asterisk.sh`
README section) — a swapfile is added automatically (RAM ≤4GB, see above),
and this plan is "fine for a couple of extensions and light personal use."
- **Option B — a lightweight utility box.** Caddy + CrowdSec + NetBird
(all near-zero RAM) plus at most one or two of the smallest apps (`ntfy`,
`vaultwarden`, `wg-easy`) — total comfortably under 500MB.
**Avoid on this tier:** anything with its own database (Mattermost,
Traccar, Nextcloud), more than one substantial app, media/AI/gaming
services. There's no headroom for a second heavy thing once the first one
is running.
## Tier 2 — 4 vCores / 4GB RAM / 120GB NVMe
Example: IONOS VPS M+, $11/mo. **This is the tier actually planned out in
detail** — see the recap below.
## What I was planning for the IONOS 4 vCPU / 4GB / 120GB box
Reconstructed from the sizing conversation, in the order decisions were made:
**Core stack (the original ask):**
- `caddy` — reverse proxy / HTTPS
- `crowdsec` — intrusion prevention
- `asterisk` — PBX, using the **shared** `coturn` service (not embedded)
- `mattermost` × 2 — genuinely isolated instances (separate dir/containers/DB
per instance), both sharing the one `coturn` service — this required
merging PR #265 (`claude/droplet-capacity-assessment-s2voix`), which
extracted `coturn` into `services/coturn.sh` and added real multi-instance
support to `services/mattermost.sh`; merged into `main` at commit `6151ede`
- `traccar` — GPS tracking
**Validated call load:** up to 9 concurrent Asterisk calls, at most 1
Mattermost call at a time, 0 screen share. Comfortably within budget —
no transcoding/conferencing/heavy-video load in this profile, so CPU has
large margin and RAM sits around 2.0-2.7GB idle with the core stack alone.
**Utility adds, agreed:**
- `ntfy`, `wg-easy`, `homebox`, `mealie`
**Explicitly declined:** `vaultwarden`, `portainer`, `syncthing`, `actualbudget`
(dropped to make room for WordPress — see below)
**Remote / cross-VLAN access:** NetBird — hosted control plane (not
self-hosted), client-only, with its embedded SSH server enabled
(`--allow-server-ssh`, JWT/OIDC-based user auth, no user SSH keypair to
manage). Already implemented in `services/base.sh` (`_base_setup_netbird()`)
as part of every box's base install — nothing further to build for this.
Chosen over self-hosting a WireGuard mesh once it was clear that (a) a
hub-routed WireGuard design makes the VPS a single point of failure for
*inter-peer* connectivity specifically, not just VPS access, and (b) a
different person ever needing access means hand-editing `authorized_keys`
on every box instead of revoking centrally — NetBird's actual value here is
solving both, not just being "zero-config."
**`wg-easy`'s role, narrowed:** kept in the utility-add list, but as a
**local-VLAN WireGuard testing ground**, not the primary remote-access path
— that's NetBird's job. The hub-routed peer mesh (`WG_ALLOWED_IPS=0.0.0.0/0`
by default, so any two enrolled peers already reach each other through the
VPS with no per-pair config) plus a `sync-ssh-aliases.sh` companion script
(reads peers straight off the live WireGuard interface via `wg show`,
generates `~/.ssh/config` Host aliases) is already built and pushed
(`claude/vps-capacity-assessment-r57vw3`, commit `4a0ec63`).
**Confirmed:** `audiobookshelf` on the VPS with HTTPS via Caddy, but
pointing it at a home-hosted library over a VPN tunnel (NetBird or wg-easy,
whichever link reaches that box) instead of storing audiobooks locally —
`services/audiobookshelf.sh` already just bind-mounts a host path, so this
means mounting a network share from that tunnel at the mount point instead
of a local directory. Avoids the disk/CPU tradeoffs of a local media
library; real bandwidth depends on home upload speed, which wasn't checked.
**Music: `emby`, music-only — not `lyrion`.** `lyrion` (LMS/Squeezebox) was
floated first since it's a purpose-built, well-regarded music server, but
ruled out for two protocol-level reasons neither Caddy nor Authelia can
paper over: its own web-UI auth is one shared server-wide password (no
per-user accounts), and its player protocol (SlimProto, port 3483) is raw
TCP with no authentication of its own, so Authelia's HTTP-only
`forward_auth` can't gate it at all. `emby` (already registered in this
repo, `media` category) solves both — real per-user accounts with
per-library access restriction, and every client protocol it uses is HTTP,
so Caddy fronts all of it cleanly. `services/emby.sh` now has a music-only
mode (prompts for this, defaults the folder to `~/music`, and the generated
README walks through adding only a Music library plus the
Dashboard → Users → Access per-user restriction steps in Emby's own setup
wizard). Tradeoff accepted knowingly: Emby is a generalist media server, not
a purpose-built one — it lacks LMS's music-specific depth (its lyrics
fetching, its many audio-focused plugins). Since there's no hardware
Squeezebox tie-in to preserve, that tradeoff was fine to make.
**Emby subsequently dropped from the near-term plan** — traded off for
WordPress capacity (below) rather than run alongside it. `services/emby.sh`'s
music-only mode is still there and ready whenever there's headroom for it
again; it just isn't part of the current baseline.
**WordPress — confirmed, 2 sites (settled), light traffic, ecommerce-capable.**
`services/wordpress.sh` (new): multi-site from the start, every site named,
each with its own **dedicated** MariaDB container (same pattern as
`services/nextcloud.sh`) — not a shared instance. Started as a shared-MariaDB
design (same resource-sharing idea as `coturn`) but switched to dedicated
per-site after weighing it against backup/restore: Kopia's generic backup
(`services/backup.sh`) stops a service's container to snapshot it, so a
shared instance would back up — and would have to be restored — as one unit
covering every site at once, not one site independently. Dedicated per-site
costs more RAM (a full MariaDB container each, ~100-150MB, instead of one
instance amortized across sites) in exchange for real isolation: each
site's database backs up and restores completely independently. Separate
databases were always required regardless of which model — WordPress's
schema uses generic table names (`wp_posts`, `wp_options`, etc.), so two
installs sharing one database with the same table prefix would collide —
the shared-vs-dedicated choice was only ever about the container/process,
never about the data being mixed. wp-cli automates the initial install
(title, admin account) so there's no per-site browser setup wizard, and PHP
limits are pre-tuned (256M memory, 64M uploads) for WooCommerce
specifically since "possible ecommerce" was part of the ask.
**Explicitly out of scope for this box** (wrong fit, not "can't run"):
- Local media servers storing media on the VPS (`jellyfin`, `immich`,
`lyrion`, and `emby`/`audiobookshelf` *without* the home-library-over-VPN
approach used above) — disk-hungry, and transcoding CPU load risks
contending with active calls.
- AI stacks (`ai-stack`, `ai-gpu`, `iopaint`, `paintplus`) — need real
VRAM/RAM most VPS plans don't have.
- Gaming (`minecraft`, `wolf`, `wolf-pair`, `sunshine`, `kyber-*`) — CPU/RAM
heavy; cloud-gaming ones need GPU passthrough.
- Cameras/NVR (`frigate*`, `sky-cam`) — needs real camera feeds; doesn't
make sense geographically on a VPS.
- `nextcloud` + `onlyoffice` — the combined PHP+DB+office-suite stack alone
would likely eat most of the remaining headroom.
- `unifi` — only worth ~300-500MB of Java if actually managing Ubiquiti
gear from this box.
- SSH `ProxyJump`/bastion-hop chaining for reaching genuinely isolated
(CGNAT, no local peer) boxes — a good idea in principle, parked for later
since NetBird already covers the current need.
## Final RAM budget for the IONOS box (settled baseline, no Emby, no actualbudget, idle)
| Service | ~RAM |
|---|---|
| OS + Docker baseline | ~400MB |
| Caddy | ~30MB |
| CrowdSec | ~150MB |
| coturn (shared) | ~40MB |
| Asterisk | ~100MB |
| Mattermost × 2 (app+Postgres each) | ~1200MB |
| Traccar (JVM) | ~425MB |
| NetBird client | ~35MB |
| ntfy, mealie | ~225MB combined |
| WordPress × 2 sites (app ~80MB + dedicated MariaDB ~120MB each) | ~400MB |
| **Total** | **~3.00GB** |
Leaves roughly **~1.09GB headroom (~27%)** out of 4GB — back into the ideal
25-30% range, between dropping `actualbudget` (~115MB) and settling on 2
sites instead of 4 (dedicated-per-site MariaDB's cost scales with site
count, so this was the single biggest lever). With the swapfile now
automatic (`ensure_swapfile`, see above) there's real insurance on top of
that margin, not instead of it. `wg-easy`,
`homebox`, and `audiobookshelf` from earlier in this doc aren't included in
this specific table — add them back in at ~25MB, ~125MB, and ~200MB
respectively if/when they're actually deployed alongside this baseline.
Deploy incrementally and check `free -h` / `docker stats` against this table
rather than trusting it blindly — each line carries real estimate
uncertainty, and they're stacked
close enough to the ceiling that it's worth confirming. If real usage runs
higher than estimated, the two Mattermost instances (~1.2GB combined) are
the single biggest lever to reconsider.
## Tier 3 — 6 vCores / 8GB RAM / 240GB NVMe
Example: IONOS VPS L+, $21/mo.
## What I was planning for the 6vCPU / 8GB / 240GB box
A much larger multi-instance spread than the Tier 2 box, sized against this
repo's multi-instance retrofit (`services/*.sh` instance-selection pattern,
see `CLAUDE.md`'s "Multi-instance services" section) plus its port-collision
scanning (`CLAUDE.md`'s "Port collision avoidance" section). The instance
counts below aren't arbitrary — a few of them ran into real limits in the
codebase as it exists today, not just RAM ceilings.
**Not actually possible as "2x" — singleton-by-design, no multi-instance
support exists:**
- `asterisk` — one PBX per box. Fixed SIP port 5060, one AMI, one
DigitalOcean-layout detection, one extension plan. A second instance
would fight the first over the same SIP/RTP ports; there's no
instance-suffix logic to make that not collide.
- `security-dashboard` — runs natively (not Docker), reads *one* Asterisk's
security log and talks to *one* CrowdSec via `cscli`. There's no second
log/CrowdSec on the box to point a second copy at.
- `sms-inbound` — also native, tied to one Asterisk + one pstn-trunk's
DID-ownership mapping. Same story as security-dashboard.
All three stay at **1x** on this box regardless of how the rest of the
stack is sized.
**Dropped from this plan, not attempted:**
- `changedetection` × 2 — only got the port-collision-scanning pass this
session, not the full multi-instance retrofit (no directory-suffix
logic), so a second install would collide with the first's
`~/docker/changedetection`. It also runs a dedicated Playwright/Chrome
sidecar per instance (~300-500MB each) — expensive for what would be a
head-to-head with retrofitting it.
- `magicmirror` × 6 — its existing multi-instance pattern (a different,
earlier design than the rest of the repo: upfront instance count, not
incremental) is hardcoded to 1-3 instances. Six would need a code change,
not just a bigger box, and 6 dashboard instances (~900MB) was a lot of
RAM for the value versus the rest of this stack.
**Screen-sharing tool — RustDesk over MeshCentral.** Both `services/rustdesk.sh`
and `services/meshcentral.sh` have real multi-instance support. RustDesk's
relay (`hbbs`/`hbbr`) is a lightweight Rust binary that doesn't decode video
itself — screen-share traffic goes peer-to-peer between clients, the relay
just proxies — so it costs ~40MB per instance versus MeshCentral's full
Node.js app per instance (~150-250MB). Chose RustDesk for the RAM headroom;
MeshCentral remains the pick if agent-based remote management (not just
screen viewing) matters more than the RAM difference.
**Everything else scales the same way it does on the Tier 2 box** — same
shared `coturn`, same NetBird base install, same dedicated-per-instance
database pattern (Traccar's own datastore, Joplin/WordPress's dedicated
Postgres/MariaDB per instance, per CLAUDE.md's backup-isolation reasoning).
`traccar` settled at **1x** (not 2x) specifically to buy back RAM — JVM
apps don't shrink well when duplicated (~425MB is mostly fixed heap
overhead, not data-proportional), so a second instance was the highest-RAM,
lowest-value item once Mattermost stayed at 3x.
**Emby** stays music-only-plus-everything (2 instances, one restricted to a
Music library per CLAUDE.md's per-user-access pattern, one unrestricted) —
idle RAM only; if the "everything" instance serves remote clients that
can't direct-play, transcoding adds real CPU plus ~200-400MB per active
session, not counted in the idle table below.
**No automatic swapfile on this box.** `ensure_swapfile()` only offers one
when RAM ≤4096MB — an 8GB box doesn't qualify, so unlike every Tier 1/2 box
in this doc, this one has no swap unless it's added by hand.
## Final RAM budget for the 6vCPU/8GB box (settled baseline, idle)
| Service | Count | ~RAM |
|---|---|---|
| OS + Docker baseline (~45-50 containers across all instances) | — | ~500MB |
| Caddy | — | ~30MB |
| CrowdSec | — | ~150MB |
| coturn (shared) | — | ~40MB |
| NetBird client | — | ~35MB |
| Asterisk | 1 (singleton) | ~100MB |
| security-dashboard + sms-inbound | 1 each (singleton) | ~95MB |
| Mattermost (app+Postgres each) | 3 | ~1800MB |
| Traccar (JVM) | 1 | ~425MB |
| ntfy | 2 | ~100MB |
| Mealie | 2 | ~350MB |
| WordPress (dedicated MariaDB/site) | 2 sites | ~400MB |
| Actual Budget | 2 | ~140MB |
| Audiobookshelf | 2 | ~400MB |
| Emby (music-only + everything) | 2 | ~450MB idle* |
| Filebrowser | 2 | ~80MB |
| FMD | 2 | ~100MB |
| Homebox | 2 | ~250MB |
| Joplin (app + dedicated Postgres each) | 2 | ~400MB |
| RustDesk relay | 2 | ~80MB |
| Vaultwarden | 2 | ~80MB |
| **Total** | | **~6.0GB** |
\* idle only — see the Emby note above for active-transcode cost, not
included here.
Leaves roughly **~2.0GB headroom (~25%)** out of 8GB — right at the bottom
of the ideal 25-30% range from the sizing rules of thumb above. Swapping
`rustdesk` for `meshcentral` narrows that to ~1.6GB (~20%) — still
workable, just tighter. CPU isn't expected to bind here (no
transcoding/AI/gaming load in this mix per the "How to size" rule of
thumb above), but 3 concurrent Mattermost calls, active Asterisk calls,
and a RustDesk/MeshCentral session all at once is the realistic worst
case worth watching, not the idle numbers in this table. Deploy
incrementally and check `free -h` / `docker stats` against this table the
same way as the Tier 2 box — if real usage runs higher than estimated,
Mattermost (~1.8GB across 3 instances) is again the single biggest lever
to reconsider.