# 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:
- [IONOS Cloud Inc. price list](https://docs.ionos.com/cloud/support/general-information/price-list/ionos-cloud-inc)
- [IONOS Object Storage — pricing model overview](https://docs.ionos.com/cloud/backup-and-storage/ionos-object-storage/overview/pricing)
- [IONOS Object Storage product page](https://cloud.ionos.com/storage/object-storage)
