Alle artikelen

MariaDB op een 4GB VPS: wat breekt en wat wel werkt

Wat ik geleerd heb door een productie multi-tenant MariaDB op een 4GB VPS te draaien - Docker memory limits, connection pool tuning, idempotente migraties en de instellingen die het verschil maken tussen draaien en swappen.

5 min leestijd
mariadbvpsdockerperformanceops

Salonnare draait op een VPS van 4GB RAM. Daar hosten we MariaDB plus een Next.js marketing site, een Node.js API, een Python worker, een nginx, een Docusaurus docs site en een DDNS daemon - alles via Docker Compose. Voor een SaaS die meerdere klanten bedient is dit krap maar niet onmogelijk. Dit is wat ik heb geleerd door het maandenlang in productie te houden zonder OOM kills of klant-zichtbare downtime.

Het mentaal model: alles is een budget

Op een 4GB VPS heeft elk Docker proces een geheugenbudget. Het OS pakt ongeveer 400 MB, dus je hebt 3.6 GB om te verdelen over al je services. Mijn huidige verdeling:

# docker-compose.saas.yml (versimpeld)
services:
  mariadb:
    deploy: { resources: { limits: { memory: 1g } } }
  server:
    deploy: { resources: { limits: { memory: 768m } } }
  worker:
    deploy: { resources: { limits: { memory: 256m } } }
  web:
    deploy: { resources: { limits: { memory: 128m } } }
  docs:
    deploy: { resources: { limits: { memory: 128m } } }
  ddns:
    deploy: { resources: { limits: { memory: 64m } } }

Totaal: ~2.4 GB toegewezen, ~1.2 GB headroom voor file system cache en transient pieken. Belangrijker dan de exacte verdeling: je zet harde limits. Zonder limit zal MariaDB graag al je RAM in zijn buffer pool gooien, en bij de eerste piek in API verkeer wordt je server-container OOM-killed.

innodb_buffer_pool_size is je belangrijkste setting

InnoDB cachet recent gelezen rijen en indexen in de buffer pool. Default is dat ongeveer 128 MB. Op een 1 GB MariaDB instance kun je hem rustig op 70-75% zetten:

# my.cnf
[mysqld]
innodb_buffer_pool_size = 700M
innodb_buffer_pool_instances = 1
innodb_log_file_size = 128M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT

O_DIRECT voorkomt double-buffering (kernel page cache + InnoDB cache van dezelfde data). Op een krappe VPS is dat letterlijk honderden MB winst. innodb_flush_log_at_trx_commit = 2 flusht logs bij elke commit naar OS, en elke seconde naar disk - een fractie minder durability voor een merkbare write-throughput verbetering. Voor een SaaS die geen financiële transacties bevat acceptabel; voor een bank niet.

Connection pool: meer is niet beter

In mijn server config:

// Drizzle/mysql2 pool config
{
  connectionLimit: 20,    // hard cap
  acquireTimeout: 10_000, // 10s max wachten op connection
  idleTimeout: 30_000,    // 30s voor unused connections vrijgeven
}

20 connections klinkt weinig, maar elke MariaDB connection kost ongeveer 1-2 MB aan thread memory. 100 connections = 200 MB die je niet in buffer pool kunt stoppen. De vuistregel: connection pool grootte = ongeveer 2× je CPU core count, niet hoger. Op een 2-core VPS is 8-12 connections genoeg, met 20 als veiligheidsmarge voor verkeerspieken.

Veel ORMs (waaronder oudere Knex configs) zetten standaard 100+ connections. Dat is een leftover uit fysieke server tijden en vandaag bijna nooit de juiste keuze.

Migraties moeten idempotent zijn

Salonnare draait nu 104 migraties bij elke server-start. Ze moeten allemaal idempotent zijn - twee keer draaien geeft hetzelfde resultaat als één keer. Anders verlies je een halve uur uptime per server-restart of, erger, krijg je een crash mid-deploy.

-- Werkt
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL;
CREATE TABLE IF NOT EXISTS audit_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  -- ...
);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);

-- Werkt niet
ALTER TABLE bookings ADD COLUMN deleted_at DATETIME NULL; -- crasht 2e keer

IF NOT EXISTS is je vriend bij ADD COLUMN, CREATE TABLE, CREATE INDEX. Bij een kolom-rename of type-change moet je wat creatiever zijn:

-- Conditional ALTER via INFORMATION_SCHEMA
SET @stmt = (
  SELECT IF(
    EXISTS(
      SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
      WHERE TABLE_NAME = 'clients' AND COLUMN_NAME = 'old_name'
    ),
    'ALTER TABLE clients CHANGE old_name new_name VARCHAR(255)',
    'SELECT 1'
  )
);
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;

Lelijk, maar wel idempotent. Voor productie lopen idempotente migraties bij elke server start zonder enig risico.

Trage queries doden je hele VPS

Op een grote server koop je je weg uit een trage query door meer hardware. Op een 4GB VPS doet één rapport-query met 5 niet-geïndexeerde joins je hele server omver - de buffer pool wordt geflusht, andere queries gaan disk i/o doen, en de hele stack vertraagt voor minuten.

Wat ik doe:

  1. slow_query_log = 1 aan met long_query_time = 2. Elke query langer dan 2 seconden komt in een log. Eens per week scan ik dat log.
  2. EXPLAIN voor elke nieuwe query met meer dan 1 join. Geen index = stop, eerst index toevoegen, dan ship.
  3. Reporting endpoints draaien op een aparte read-replica. Op grote VPS' triviaal, op 4GB net haalbaar via een second container met gecapte resources.

Backup zonder downtime

mysqldump --single-transaction op InnoDB tabellen geeft een consistente snapshot zonder write-lock. Eén keer per nacht via cron, output naar een Cloudflare R2 bucket:

docker exec mariadb mariadb-dump \
  --single-transaction \
  --quick \
  --routines \
  --triggers \
  --all-databases \
  | gzip | rclone rcat r2:salonnare-backups/$(date +%F).sql.gz

7 dagen retention, daarna prune. Restore getest één keer per kwartaal - backups die je nooit restoret zijn geen backups, het is hopen.

Wat ik in productie heb leren vermijden

  • MyISAM tabellen. Geen transacties, geen row-level locking, lossere recovery. Op moderne MariaDB is er geen reden om iets anders dan InnoDB te gebruiken.
  • TEMPORARY tabellen voor reporting. Vreten geheugen, en op disk gespilled is traag. Schrijf je rapport-query als een normale SELECT met CTEs of subqueries.
  • Niet-geïndexeerde ORDER BY op grote tabellen. Filesort gebruikt disk space (/tmp in container) en blokkeert andere queries.
  • SELECT * op tabellen met BLOB kolommen. Zelfs als je de blob niet leest, MariaDB streamt de hele rij. Selecteer expliciet de kolommen die je nodig hebt.

Wat ik adviseer

  • Begin met harde memory limits per container. Geen limit = OOM kill bij eerste piek.
  • Zet innodb_buffer_pool_size op ~70% van je MariaDB container limit, met O_DIRECT.
  • Cap je connection pool op 2× CPU count, niet hoger. Meer connections = meer thread memory, geen extra throughput.
  • Houd elke migratie idempotent vanaf dag 1. Achteraf retro-fitten is pijnlijk.
  • Slow query log aan, een keer per week scannen, missing indexes toevoegen.

Een 4GB VPS is niet de eindstation voor je SaaS, maar het is een prima starting point. Salonnare draait er stabiel meer dan een jaar op met een groeiend aantal tenants, en een upgrade naar 8GB staat pas op de roadmap zodra de buffer pool niet meer past.

Nick van Iersel

Auteur van deze site

Nick van Iersel

Full-stack Developer & IT Consultant

Nick is een Nederlandse full-stack developer en IT consultant uit Waalwijk met ruim zes jaar ervaring in productie-software. Hij richt zich op Next.js, TypeScript, React en Node.js voor websites, SaaS platformen en mobile apps, en werkt zowel op uurbasis als op projectbasis met vaste prijs.

Ready to make your idea a reality?

Let's build something amazing together