Files
ubuntu-post-install/docs/vps-sizing-recommendations.md
T
Claude ca239a3886 Retire the shared coturn service — every WebRTC/SIP service now runs its own
Shared coturn (services/coturn.sh, ensure_coturn_user in lib/common.sh) is no
longer an installable or usable option anywhere in this repo. It's moved to
attic/coturn.sh (with tools/coturn-test-check.sh alongside it), which is
outside setup.sh's services/*.sh glob, so it never registers, never appears
in the menu, and `sudo ./setup.sh coturn` now fails with "unknown service".

Asterisk and Mattermost each already had an opt-out to run their own
dedicated coturn instead of the shared one; that opt-out is now the only
behavior — the shared-coturn preference, the opt-out prompt, and every
ensure_coturn_user() call site are gone. find_free_coturn_range()
(lib/common.sh) is what makes unconditional dedicated coturn safe: it scans
every coturn-owning service's own .env on the box for already-claimed relay
ranges and picks one that can't collide, so Asterisk + any number of
Mattermost instances can each run their own coturn on one box without the
relay-port collisions this repo's coturn history warns about.

Existing installs still pointed at a shared coturn container are left
running as-is on `update` (no silent migration attempt against a service
that no longer exists to heal against) — a full/fresh reinstall is the
migration path, which generates a new dedicated coturn with fresh
credentials and says so.

Also updates CLAUDE.md's coturn guidance for future service authors,
attic/README.md with the retirement rationale, and stale
services/coturn.sh path references in services/asterisk.sh,
tools/pstn-test-check.sh, README.md, and docs/vps-sizing-recommendations.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crt4ymNEHEbWqscB1qvZgC
2026-08-13 02:12:32 +00:00

20 KiB
Raw Blame History

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).
  • Historical note, no longer applicable: this doc's Tier 2/3 plans below were sized around one shared coturn instance instead of each WebRTC- capable service (Asterisk, Mattermost) embedding its own — it saved a container per consumer and avoided relay-port collisions between them. That shared-coturn service has since been retired from this repo (see attic/coturn.sh); every service now runs its own dedicated coturn, and lib/common.sh's find_free_coturn_range() avoids the same relay-port collisions by scanning each coturn-owning service's .env instead. Budget a coturn container per WebRTC-capable service/instance, not one shared ~40MB line, when re-planning a box from scratch.

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 + its own dedicated coturn 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.

IONOS Object Storage pricing

Relevant to services/immich.sh's S3 storage engine (thumbnails, encoded video, and new uploads offloaded to object storage instead of local VPS disk — see that file's IMMICH_STORAGE_ENGINE=s3 support) or any other service pointed at an S3-compatible bucket instead of a local bind mount. Storage cost was already known from IONOS chat support (~$0.50/100GB/month checked out); API and transfer costs were the unknowns — pulled from IONOS's own published price list (docs.ionos.com, IONOS CLOUD Inc. price list — USD; the EU entity, IONOS SE, may list EUR-denominated rates that differ slightly) rather than the general pricing-comparison sites, which had conflicting/stale numbers for the API-cost line.

Item Cost
Storage $0.00487 / GB / 30 days (~$4.99/TB/month, ~$0.49/100GB/month)
API requests — PUT / COPY / POST / LIST / GET / DELETE Free — no per-request charge on any operation
Inbound transfer (upload to the bucket) Free
Outbound transfer (download from the bucket) — up to 2TB/mo Free
Outbound transfer — next 8TB (2-10TB/mo) $0.036 / GB
Outbound transfer — next 40TB (10-50TB/mo) $0.030 / GB
Outbound transfer — next 100TB (50-150TB/mo) $0.024 / GB
Outbound transfer — over 150TB/mo $0.018 / GB

The outbound-transfer tiers are shared across the whole IONOS contract, not scoped to Object Storage alone — VPS egress and bucket egress draw from the same cumulative monthly pool. For a personal Immich-style workload (thumbnail/originals fetched by a handful of client devices, not a public CDN), 2TB/month of combined egress is a lot of headroom — this is effectively storage-cost-only in practice (~$0.49/100GB/month), with API calls and typical download volume both landing in the free tier.

Sources: