The Perfect Server: AlmaLinux and Rocky Linux 9 and 10 (Apache, PHP-FPM, MariaDB, Postfix, Dovecot, Rspamd, PowerDNS)
A complete hosting server, built by hand and tested end to end: websites on several PHP versions with HTTPS, MariaDB, a full mail stack and authoritative DNS.
This is the whole hosting stack on one AlmaLinux or Rocky Linux server: the same set of services a control panel runs, assembled by hand so you know what every piece does. It is built in four parts, and each part is a tutorial of its own, so you can stop after any of them and still have something that works:
- Initial server setup — updates, hostname, time, firewall, automatic security updates, key-only SSH.
- Mail server — Postfix, Dovecot, Rspamd and DKIM, with a Let’s Encrypt certificate.
- Websites on several PHP versions — Apache, PHP 8.4 and 8.2 from Remi, one FPM pool and one Linux user per site.
- This page — MariaDB with a database per site, HTTPS for every site, and authoritative DNS with PowerDNS. It also covers the one place where parts 2 and 3 collide.
Follow parts 1 to 3 first, in that order. The whole sequence, including this page, is tested in one run on a fresh server for every OS listed above.
MariaDB, with a database per site
The distribution’s MariaDB is supported for the life of the OS release, so use it:
dnf -y install mariadb-servercat > /etc/my.cnf.d/zz-local.cnf <<'EOF'[mysqld]bind-address = 127.0.0.1EOFsystemctl enable --now mariadbbind-address = 127.0.0.1 keeps the database off the network: only programs on this
server can connect. Port 3306 is not open in the firewall either, but two locks are
better than one.
MariaDB’s root account logs in through the Unix socket, so only the system’s root
user can use it, and no password is needed or stored. Remove the anonymous accounts and
the test database the package ships with:
mariadb -e "DELETE FROM mysql.global_priv WHERE User=''; DROP DATABASE IF EXISTS test; FLUSH PRIVILEGES;"That is what the interactive mariadb-secure-installation script does, without the
questions.
Give each site its own database and its own database user, so a site can only reach its
own data. This creates both for site1 with a random password, and stores the
credentials in a file only that site can read:
DBPASS=$(openssl rand -base64 18)mariadb -e "CREATE DATABASE site1 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"mariadb -e "CREATE USER 'site1'@'localhost' IDENTIFIED BY '$DBPASS';"mariadb -e "GRANT ALL PRIVILEGES ON site1.* TO 'site1'@'localhost';"cat > /var/www/site1/db.php <<EOF<?phpreturn ['host' => 'localhost', 'name' => 'site1', 'user' => 'site1', 'pass' => '$DBPASS'];EOFchown site1:site1 /var/www/site1/db.phpchmod 600 /var/www/site1/db.phpdb.php sits outside public/, so the web server never serves it, and its mode 600
means only the site1 user, the one site1’s PHP pool runs as, can read it.
Check that site1’s PHP can reach its database:
cat > /var/www/site1/public/db-check.php <<'EOF'<?php$c = require __DIR__ . '/../db.php';$db = new mysqli($c['host'], $c['user'], $c['pass'], $c['name']);echo $db->query('SELECT DATABASE()')->fetch_row()[0], "\n";EOFchown site1:apache /var/www/site1/public/db-check.phpcurl -fsS -H 'Host: site1.example.com' http://127.0.0.1/db-check.php | grep -x site1rm /var/www/site1/public/db-check.phpA home for the server’s own name
Parts 2 and 3 collide at this point. The mail server’s certificate for
server.example.com was issued with certbot --standalone, which runs its own web
server on port 80 for a moment. Apache owns port 80 now, so that certificate would fail
to renew in two months, and Postfix and Dovecot would start serving an expired
certificate.
The fix is to give server.example.com a virtual host in Apache, then let certbot renew
through Apache from now on:
mkdir -p /var/www/server/publicecho "server.example.com" > /var/www/server/public/index.htmlcat > /etc/httpd/conf.d/00-server.conf <<'EOF'<VirtualHost *:80> ServerName server.example.com DocumentRoot /var/www/server/public</VirtualHost>EOFsystemctl reload httpd
dnf -y install mod_ssl python3-certbot-apachecertbot reconfigure --cert-name server.example.com --apache --non-interactivecertbot renew --cert-name server.example.com --dry-runcertbot reconfigure changes how a certificate renews without issuing a new one, and
keeps the deploy hook from part 2. The dry run proves the next renewal will work.
HTTPS for every site
With mod_ssl and certbot’s Apache plugin installed, one command per site gets a
certificate, writes an HTTPS virtual host next to the existing one, and redirects HTTP
to HTTPS:
certbot --apache -d site1.example.com --non-interactive --agree-tos -m postmaster@example.com --redirectcertbot --apache -d site2.example.com --non-interactive --agree-tos -m postmaster@example.com --redirectcertbot copies the SetHandler line into the new HTTPS virtual host, so each site keeps
its PHP version over HTTPS. Check that it did, and that plain HTTP now redirects:
curl -fsS https://site1.example.com/ | grep -E '^8\.4\.[0-9]+ site1$'curl -fsS https://site2.example.com/ | grep -E '^8\.2\.[0-9]+ site2$'curl -sS -o /dev/null -w '%{http_code}\n' http://site1.example.com/ | grep -x 301The certbot-renew.timer enabled in part 2 renews all of these certificates.
Authoritative DNS with PowerDNS
The last piece answers DNS queries for your domains, so their records live on the server that hosts them. PowerDNS keeps zones in a database, and SQLite is plenty for one server:
dnf -y install pdns pdns-backend-sqlite sqlite bind-utilssqlite3 /var/lib/pdns/pdns.sqlite3 < /usr/share/doc/pdns/schema.sqlite3.sqlchown -R pdns:pdns /var/lib/pdns
cat > /etc/pdns/pdns.conf <<'EOF'launch=gsqlite3gsqlite3-database=/var/lib/pdns/pdns.sqlite3local-address=0.0.0.0, ::setuid=pdnssetgid=pdnszone-cache-refresh-interval=0default-soa-content=ns1.@ hostmaster.@ 0 10800 3600 604800 3600EOF
systemctl enable --now pdnsfirewall-cmd --permanent --add-service=dnsfirewall-cmd --reloadschema.sqlite3.sql creates an empty database. The other *_schema.sqlite3.sql files
next to it are upgrades between old versions and are not needed on a new server.
Two settings are easy to miss:
zone-cache-refresh-interval=0— by default PowerDNS loads its list of zones at startup and refreshes it only every five minutes. A zone created withpdnsutilwhile the server runs answersREFUSEDuntil then, which looks exactly like a broken setup. On one server, turning the cache off costs nothing.default-soa-content— the SOA record every new zone gets. Without it, PowerDNS writesa.misconfigured.dns.server.invalidas the primary nameserver.@stands for the zone’s own name.
Create the zone for example.com and its records. 203.0.113.10 stands in for your
server’s public IP. The DKIM record is read straight from the key file created in part 2:
pdnsutil zone create example.compdnsutil rrset add example.com example.com NS ns1.example.com.pdnsutil rrset add example.com ns1.example.com A 203.0.113.10pdnsutil rrset add example.com example.com A 203.0.113.10pdnsutil rrset add example.com server.example.com A 203.0.113.10pdnsutil rrset add example.com site1.example.com A 203.0.113.10pdnsutil rrset add example.com site2.example.com A 203.0.113.10pdnsutil rrset add example.com example.com MX "10 server.example.com."pdnsutil rrset add example.com example.com TXT '"v=spf1 mx -all"'pdnsutil rrset add example.com _dmarc.example.com TXT '"v=DMARC1; p=none; rua=mailto:postmaster@example.com"'pdnsutil rrset add example.com mail._domainkey.example.com TXT "$(grep -o '"[^"]*"' /var/lib/rspamd/dkim/example.com.mail.txt | paste -sd' ')"pdnsutil zone check example.comThe DKIM key is longer than the 255 characters a single DNS string can hold, so it is
published as several quoted strings in one record. The grep … | paste keeps the pieces
exactly as rspamadm split them.
Ask your own server, the way the rest of the internet will:
dig +short @127.0.0.1 MX example.com | grep -x '10 server.example.com.'dig +short @127.0.0.1 A site1.example.com | grep -x '203.0.113.10'dig +short @127.0.0.1 TXT mail._domainkey.example.com | grep 'v=DKIM1'To make this server authoritative for real, register ns1.example.com with its IP as a
glue record at your domain registrar, and set it as the domain’s nameserver.
The registrar will want two nameservers. One server is a single point of failure for
DNS, so add a secondary: a second server running PowerDNS, or your provider’s secondary
DNS service, transferring the zone from this one.
What you have now
| Service | Ports | Software |
|---|---|---|
| Websites, HTTPS, several PHP versions | 80, 443 | Apache, PHP-FPM 8.4 and 8.2 (Remi) |
| Databases | localhost only | MariaDB |
| Receiving mail | 25 | Postfix |
| Sending mail (users) | 587, 465 | Postfix + Dovecot SASL |
| Reading mail | 993 | Dovecot |
| Spam filtering, DKIM | — | Rspamd |
| DNS | 53 | PowerDNS |
| Certificates | — | Let’s Encrypt (certbot), renewing on a timer |
What is still yours to do by hand on this server: adding a site means a user, a pool, a virtual host, a certificate, a database and DNS records. Adding a mailbox means two files. Each of those steps is in this series.
The CorePanel way
This is the stack CorePanel installs, with its own web server, CoreHttpd, in place of Apache. Everything on this page becomes one form: creating a hosting account gives it a Linux user, an FPM pool on the PHP version you pick, a document root, a certificate that renews itself and a mail domain with a DKIM key, and, when CorePanel serves your DNS, its zone with the records already in it. Databases, mailboxes, DNS records and PHP versions are then managed from the panel, per account.
curl -fsSL https://get.corepanel.net/install.sh | bashThe installer runs on a server prepared with part 1, and CorePanel Personal is free. See Getting started.