What you will set up
A managed database service gives you an address, a user name and a password, and looks after the rest. This guide builds the same on your own server: MariaDB with one database and one account per app, settings sized for the server, TLS, nightly backups, and access only from this server and from the servers your apps run on, over WireGuard. The database port is never open to the internet.
MariaDB began in 2009 as a fork of MySQL, led by MySQL's original author, Michael "Monty" Widenius. It is developed by the company MariaDB, based in the United States, together with the MariaDB Foundation, a non-profit that looks after the open-source project. It is open source under the GPL version 2, and most apps written for MySQL work with it. The container image is the official mariadb image on Docker Hub, maintained by the MariaDB Foundation. On our test server MariaDB made no outgoing connections.
Here MariaDB runs as a single container under a user of its own called mariadb, set up as in the Podman guide. It needs no Caddy: apps talk to it directly, over the tunnel.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- MariaDB 12.3.3, the current long-term release, started with a root password from a Podman secret, and root could only log in from inside the container.
- An app account, from an app server at the other end of a WireGuard tunnel, could create tables in its own database, but not open another database, create databases or read files on the server. Without TLS, it was refused.
- The
mariadbclient and a Python app connected over TLS 1.3 and checked the server's certificate. A client with the wrong certificate authority was refused. - Nothing listened on the server's public IPv4 or IPv6 address, and from the internet, port 3306 did not answer.
- A sysbench run before and after the memory settings.
- A nightly dump was restored next to the real database, and into an empty server with its accounts. A physical backup with
mariadb-backupwas restored over the live data. - A major upgrade from MariaDB 11.8 to 12.3 by changing the image tag.
- Everything came back by itself after a reboot, including the tunnel address.
After a reboot, the whole server used 1.6 GB of memory, of which the MariaDB container used 1.2 GB: MariaDB sets up part of its 4 GB cache when it starts, and the rest fills as it reads your data.
Before you start
You need:
- a server set up as in the Podman guide, with ufw on as in Secure a new Debian server;
- WireGuard on the server, with the tunnel address
10.8.0.1, and each app server added as a device, as in Adding more devices. The examples use10.8.0.2for the app server.
An app server that is another Melonslab server in the same network needs Connect two servers first, so WireGuard can reach it. The tunnel then adds encryption and a fixed address for each server.
The examples use appdb and appuser for the app's database and account, and 203.0.113.10 for the database server's public address. Replace them with your own throughout. MariaDB uses port 3306, as MySQL does, so run one of them per server.
1. Create the user
As root:
useradd -m -s /bin/bash mariadb
loginctl enable-linger mariadb
machinectl shell mariadb@
Everything up to step 7 runs as mariadb.
2. Create the secrets
openssl rand -base64 24 | tr -d '\n' | podman secret create mariadb-root-password -
openssl rand -base64 24 | tr -d '\n' | podman secret create appdb-password -
The first is MariaDB's root password, the second the password of your app's account. Podman stores them, so they are in no file below. To see one, for your password manager or the app's settings:
podman secret inspect --showsecret --format '{{.SecretData}}' appdb-password
3. Make a TLS certificate
WireGuard already encrypts the traffic between your servers. TLS adds a check that the app is talking to your database and not to something else on the tunnel, and keeps the connection encrypted if it ever takes another path. Since nobody outside your servers connects, you need no public certificate: make your own small certificate authority, and a certificate for the server signed by it.
mkdir -p ~/mariadb/ca ~/mariadb/tls ~/mariadb/conf.d
cd ~/mariadb/ca
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 3650 \
-subj "/CN=MariaDB CA" -addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" -keyout ca-key.pem -out ca.pem
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=10.8.0.1" \
-addext "subjectAltName=IP:10.8.0.1,IP:127.0.0.1" -keyout ../tls/server-key.pem -out server.csr
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem -days 3650 -copy_extensions copy -out ../tls/server-cert.pem
cp ca.pem ../tls/
podman unshare chown 999:999 ../tls/server-key.pem
ca.pemis the certificate authority. Every app server gets a copy, to check the server with.ca-key.pemsigns certificates. It stays in~/mariadb/ca, which the container never sees.- The server's certificate names the addresses apps connect to,
10.8.0.1and127.0.0.1. Clients compare it with the address they used. - Both are valid for ten years. Put a reminder in your calendar, and repeat the last four commands before then.
- The last line gives the server's key to the user MariaDB runs as inside the container, user ID 999, so MariaDB can read it and other users on the server cannot.
4. Size it for the server
MariaDB's defaults are made to start on any machine, with 128 MB for its own cache. Create ~/mariadb/conf.d/server.cnf:
[mariadb]
# Listen on this server and on the WireGuard tunnel only
bind_address = 127.0.0.1,10.8.0.1
skip_name_resolve
# TLS for connections over the network
ssl_ca = /etc/mysql/tls/ca.pem
ssl_cert = /etc/mysql/tls/server-cert.pem
ssl_key = /etc/mysql/tls/server-key.pem
# Sized for 2 vCPU and 8 GB of memory
innodb_buffer_pool_size = 4G
innodb_log_file_size = 1G
max_connections = 200
tmp_table_size = 64M
max_heap_table_size = 64M
bind_addressmakes MariaDB listen on the server itself and on the tunnel, and nowhere else. Even with the firewall off, nothing listens on the public address. MariaDB listens on10.8.0.1even before WireGuard has created it at boot, so the order they start in does not matter.skip_name_resolvemakes MariaDB match accounts by address only, without looking names up in DNS.innodb_buffer_pool_sizeis MariaDB's cache for tables and indexes. Unlike PostgreSQL, MariaDB reads past the system's file cache, so on a server that only runs the database, half to two thirds of the memory is the usual size. 4 GB leaves room for the system, connections and a few other containers.innodb_log_file_sizeis the redo log, where changes go before they are written to the tables. A larger log smooths out heavy writing.max_connectionsis raised from the default 151. Each connection uses a little memory of its own, a few MB at most for ordinary queries.tmp_table_sizeandmax_heap_table_sizelet a query keep larger temporary tables in memory before it moves them to disk.
On the Titanium plan, with 16 GB, use innodb_buffer_pool_size = 10G.
5. Run MariaDB
mkdir -p ~/.config/containers/systemd
Create ~/.config/containers/systemd/mariadb.container:
[Unit]
Description=MariaDB database server
[Container]
ContainerName=mariadb
Image=docker.io/library/mariadb:12.3
Network=host
Volume=mariadb-data:/var/lib/mysql
Volume=%h/mariadb/conf.d:/etc/mysql/conf.d:ro
Volume=%h/mariadb/tls:/etc/mysql/tls:ro
Secret=mariadb-root-password
Secret=mariadb-root-password,type=env,target=MYSQL_PWD
Environment=MARIADB_ROOT_PASSWORD_FILE=/run/secrets/mariadb-root-password
Environment=MARIADB_ROOT_HOST=localhost MARIADB_AUTO_UPGRADE=1
HealthCmd=healthcheck.sh --connect --innodb_initialized
HealthInterval=10s
Notify=healthy
AutoUpdate=registry
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=default.target
What the lines do:
mariadb:12.3is the current long-term release, supported until 2029.AutoUpdate=registryinstalls its bug and security fixes, such as 12.3.3 to 12.3.4, but never moves you to a new major version.Network=hostputs MariaDB on the server's own network, like Caddy. It then listens on exactly the addresses in step 4, and sees each client's real address, which the accounts in step 6 depend on.- The root password comes from the secret.
MARIADB_ROOT_HOST=localhostcreates root only for connections from inside the container, so root cannot log in over the network at all. - The second
Secretline gives the same password to themariadbcommand inside the container, sopodman exec mariadb mariadblogs you in as root without typing it. MARIADB_AUTO_UPGRADE=1updates MariaDB's system tables after a new version, and saves a copy of them first.Notify=healthymakes the service count as started only when MariaDB answers, so everything that waits for it waits long enough.
Start it, and switch on automatic updates:
systemctl --user daemon-reload
systemctl --user start mariadb
systemctl --user enable --now podman-auto-update.timer
podman exec mariadb mariadb -e "SELECT VERSION(), @@innodb_buffer_pool_size, @@have_ssl"
VERSION() @@innodb_buffer_pool_size @@have_ssl
12.3.3-MariaDB-ubu2404 4294967296 YES
The first start downloads the 346 MB image and takes about half a minute.
6. Create a database and an account for each app
Give every app its own database and its own account, so a leak in one app does not open the others. Create them as root in MariaDB, with the password from step 2:
pw=$(podman secret inspect --showsecret --format '{{.SecretData}}' appdb-password)
podman exec -i mariadb mariadb <<SQL
CREATE DATABASE appdb;
CREATE USER 'appuser'@'10.8.0.2' IDENTIFIED BY '$pw' REQUIRE SSL;
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX, REFERENCES
ON appdb.* TO 'appuser'@'10.8.0.2';
SQL
'appuser'@'10.8.0.2'is an account that can only log in from the app server's tunnel address. The same name from any other address is refused.REQUIRE SSLrefuses the account without TLS.- The rights cover what an app and its migrations need in its own database: read and write rows, and create, change and remove tables and indexes. The account cannot create databases or accounts, or read files on the server.
We checked from the app server, logged in as appuser:
$ mariadb ... mysql
ERROR 1044 (42000): Access denied for user 'appuser'@'10.8.0.2' to database 'mysql'
$ mariadb ... -e "CREATE DATABASE other"
ERROR 1044 (42000) at line 1: Access denied for user 'appuser'@'10.8.0.2' to database 'other'
LOAD_FILE('/etc/passwd') returned nothing. Repeat the block for each app, with its own names and a secret of its own.
The address in the account is where the connection comes from:
- an app on another server, over the tunnel: its tunnel address, such as
10.8.0.2; - an app on this server, outside a container, or through an SSH tunnel (step 7):
127.0.0.1; - an app in a container on this server, under another user:
10.8.0.1, because it reaches MariaDB through the tunnel address and comes from there.
7. Let your apps in
Go back to root with exit, and open port 3306 on the tunnel only:
ufw allow in on wg0 to any port 3306 proto tcp
Copy the certificate authority, /home/mariadb/mariadb/ca/ca.pem, to each app server, for example to /etc/ssl/certs/appdb-ca.pem. It is not secret.
With the mariadb client. On the app server, apt install mariadb-client, then:
mariadb -h 10.8.0.1 -u appuser -p --ssl-ca=/etc/ssl/certs/appdb-ca.pem appdb \
-e "SELECT CURRENT_USER(); SHOW STATUS LIKE 'Ssl_version'"
CURRENT_USER()
appuser@10.8.0.2
Variable_name Value
Ssl_version TLSv1.3
From an app. In most apps, the connection string is mysql://appuser:PASSWORD@10.8.0.1:3306/appdb, with the path to ca.pem in its TLS settings. In Python, with PyMySQL (apt install python3-pymysql):
import os
import pymysql
conn = pymysql.connect(
host="10.8.0.1",
user="appuser",
password=os.environ["DB_PASSWORD"],
database="appdb",
ssl_ca="/etc/ssl/certs/appdb-ca.pem",
ssl_verify_cert=True,
ssl_verify_identity=True,
)
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM notes")
print(cur.fetchone()[0])
conn.close()
ssl_verify_cert and ssl_verify_identity make PyMySQL check that the certificate is signed by your authority and names 10.8.0.1. Give both: with only ssl_verify_identity, the version in Debian 13 stops with Cannot set verify_mode to CERT_NONE when check_hostname is enabled. Our test app read back 1,000 rows over TLS 1.3, and with another authority's ca.pem it was refused with certificate verify failed.
From your own computer. An SSH tunnel needs no change on the server. On your computer:
ssh -N -L 3307:localhost:3306 root@203.0.113.10
While it runs, a database tool on your computer connects to 127.0.0.1 port 3307, and MariaDB sees the connection come from 127.0.0.1, so it needs an account for that address (step 6).
8. Check what others see
On the server:
ss -tlnp | grep 3306
LISTEN 0 90 127.0.0.1:3306 0.0.0.0:* users:(("mariadbd",pid=1164,fd=24))
LISTEN 0 90 10.8.0.1:3306 0.0.0.0:* users:(("mariadbd",pid=1164,fd=25))
Nothing on the public address, and nothing on IPv6. From a computer that is not on the tunnel, try the public address:
nc -vz -w 5 203.0.113.10 3306
nc: connect to 203.0.113.10 port 3306 (tcp) timed out: Operation now in progress
ufw drops the attempt without an answer, and on the server journalctl -k | grep 'DPT=3306' shows it as [UFW BLOCK]. Over the tunnel, the app server only gets in with the accounts you created. Our tests got:
--skip-ssl ERROR 1045 (28000): Access denied for user 'appuser'@'10.8.0.2' (using password: YES)
-u root ERROR 1045 (28000): Access denied for user 'root'@'10.8.0.2' (using password: YES)
another CA ERROR 2026 (HY000): TLS/SSL error: self-signed certificate in certificate chain
9. Measure
We measured with sysbench on a 1.9 GB test database, eight tables of a million rows, 16 clients for 60 seconds, from the server itself. Each test ran twice:
| Settings | Read-write (transactions/s) | Read-only (transactions/s) |
|---|---|---|
| Default | 275 and 334 | 592 and 618 |
| Step 4 | 283 and 411 | 716 and 724 |
Read-only went up by about 20 %. Read-write varied more between two runs than between the settings, so run each test more than once before you compare. To run it yourself, install sysbench on the server, create a sbtest database and an account for 127.0.0.1, and use sysbench oltp_read_write --mysql-host=127.0.0.1 --tables=8 --table-size=1000000 --threads=16 --time=60 with prepare, then run. Remove the database and account afterwards.
10. Back up every night
mariadb-dump writes a consistent copy of a database while apps keep using it. As mariadb (machinectl shell mariadb@), create ~/.local/bin/mariadb-dump-all:
#!/bin/sh
# Dump the accounts and every database, and keep 14 days of dumps
set -e
umask 077
dir=$HOME/backup
day=$(date +%F)
podman exec mariadb mariadb-dump --system=users --insert-ignore > "$dir/users-$day.sql"
for db in $(podman exec mariadb mariadb -N -e "SHOW DATABASES WHERE \`Database\` NOT IN ('information_schema','performance_schema','sys','mysql')"); do
podman exec mariadb mariadb-dump --single-transaction --routines --events "$db" | gzip > "$dir/$db-$day.sql.gz"
done
find "$dir" -type f -mtime +13 -delete
chmod 755 ~/.local/bin/mariadb-dump-all
mkdir -p ~/backup ~/.config/systemd/user
The users file holds every account, its rights and its password hash, which the database dumps leave out. Only mariadb can read the files. Run it every night with a systemd service and timer. Create ~/.config/systemd/user/mariadb-dump.service:
[Unit]
Description=Dump MariaDB databases
[Service]
Type=oneshot
ExecStart=%h/.local/bin/mariadb-dump-all
And ~/.config/systemd/user/mariadb-dump.timer:
[Unit]
Description=Dump MariaDB databases every night
[Timer]
OnCalendar=*-*-* 02:30
Persistent=true
[Install]
WantedBy=timers.target
Switch it on, and run it once now:
systemctl --user daemon-reload
systemctl --user enable --now mariadb-dump.timer
systemctl --user start mariadb-dump.service
ls -l ~/backup
-rw------- 1 mariadb mariadb 5524 Oct 14 01:57 appdb-2026-10-14.sql.gz
-rw------- 1 mariadb mariadb 4482 Oct 14 01:57 users-2026-10-14.sql
Test a restore. Restore into a new database, next to the real one:
podman exec mariadb mariadb -e "CREATE DATABASE appdb_restore"
gunzip -c ~/backup/appdb-2026-10-14.sql.gz | podman exec -i mariadb mariadb appdb_restore
podman exec mariadb mariadb -e "SELECT COUNT(*) FROM appdb_restore.notes; DROP DATABASE appdb_restore"
Use one of your own tables in the count. Our test table, notes, came back with all its 1,000 rows.
To rebuild a whole server, set it up with steps 1 to 5, then load the users file first and each database after it:
podman exec -i mariadb mariadb < ~/backup/users-2026-10-14.sql
podman exec mariadb mariadb -e "CREATE DATABASE appdb"
gunzip -c ~/backup/appdb-2026-10-14.sql.gz | podman exec -i mariadb mariadb appdb
We did this in an empty MariaDB: the accounts came back with their addresses, rights and passwords, and the table with all its rows. The users file also sets root's password back to the one in the backup, so after loading it, root logs in with the old password. Keep the passwords from step 2 in your password manager.
Copy ~/backup off the server every night, for example with restic. The dumps hold all your data.
Physical backups for large databases
A dump is restored by running every statement again, which takes long for databases of many GB. mariadb-backup, in the same image, copies the data files themselves while MariaDB runs:
umask 077
podman exec mariadb sh -c 'mariadb-backup --backup --stream=xbstream --target-dir=/tmp 2>/tmp/backup.log' > ~/backup/mariadb-physical.xb
umask 077 makes the file readable by mariadb only, like the dumps: it holds every database and the accounts. To restore it, MariaDB has to be stopped and its data replaced:
systemctl --user stop mariadb
podman volume rm mariadb-data
podman volume create mariadb-data
podman run --rm -i -v mariadb-data:/var/lib/mysql docker.io/library/mariadb:12.3 sh -c \
'mkdir /tmp/r && mbstream -x -C /tmp/r && mariadb-backup --prepare --target-dir=/tmp/r && mariadb-backup --copy-back --target-dir=/tmp/r && chown -R mysql:mysql /var/lib/mysql' \
< ~/backup/mariadb-physical.xb
systemctl --user start mariadb
We deleted half of the test table after a physical backup, restored it this way, and all 1,000 rows were back. Restore with the same version of MariaDB that made the backup.
11. Update MariaDB
Bug and security fixes, such as 12.3.3 to 12.3.4, come with podman auto-update once a day, and MariaDB restarts for a few seconds. To see if one is waiting:
podman auto-update --dry-run
Major versions. A long-term release comes once a year and is supported for three years. To move to a new one, run a backup, change the tag in mariadb.container, and restart:
systemctl --user start mariadb-dump.service
sed -i 's|mariadb:12.3|mariadb:13.3|' ~/.config/containers/systemd/mariadb.container
systemctl --user daemon-reload
systemctl --user restart mariadb
podman logs mariadb 2>&1 | grep -i upgrade
We tested this from 11.8 to 12.3, with the same steps. MariaDB started on the new version with the old data, and MARIADB_AUTO_UPGRADE backed up and updated the system tables by itself:
[Note] [Entrypoint]: Backing up system database to system_mysql_backup_11.8.9-MariaDB.sql.zst
Major version upgrade detected from 11.8.9-MariaDB to 12.3.3-MariaDB. Check required!
[Note] [Entrypoint]: Finished mariadb-upgrade
It took 20 seconds. Go from one long-term release to the next, and read the release notes first. Use the tag of the version you move to; 13.3 above is only an example. A step back to the old version is not supported: restore the backup instead.
Troubleshooting
Access denied for user 'appuser'@'10.8.0.2'. The password, the address or TLS is wrong. Check that the client uses TLS, since the account has REQUIRE SSL, and that an account exists for exactly that address: podman exec mariadb mariadb -e "SELECT user, host FROM mysql.user".
Host '10.8.0.1' is not allowed to connect. The app runs in a container on this server, and comes from the tunnel address. Create its account for 10.8.0.1, as in step 6.
certificate verify failed: CA cert does not include key usage extension. The certificate authority was made without the keyUsage line in step 3, which Python 3.13 and newer require. Make it again with all of step 3, and copy the new ca.pem to the app servers.
TLS/SSL error: self-signed certificate in certificate chain. The client has another ca.pem than the one that signed the server's certificate. Copy ~/mariadb/ca/ca.pem from the server again.
The app times out connecting to 10.8.0.1. Check that WireGuard is up on both servers (wg show), and that the ufw rule from step 7 is there: ufw status | grep 3306.