← Tutorials
Server stack advanced · ~150 min ·

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:

  1. Initial server setup — updates, hostname, time, firewall, automatic security updates, key-only SSH.
  2. Mail server — Postfix, Dovecot, Rspamd and DKIM, with a Let’s Encrypt certificate.
  3. Websites on several PHP versions — Apache, PHP 8.4 and 8.2 from Remi, one FPM pool and one Linux user per site.
  4. 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:

Terminal window
dnf -y install mariadb-server
cat > /etc/my.cnf.d/zz-local.cnf <<'EOF'
[mysqld]
bind-address = 127.0.0.1
EOF
systemctl enable --now mariadb

bind-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:

Terminal window
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:

Terminal window
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
<?php
return ['host' => 'localhost', 'name' => 'site1', 'user' => 'site1', 'pass' => '$DBPASS'];
EOF
chown site1:site1 /var/www/site1/db.php
chmod 600 /var/www/site1/db.php

db.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:

Terminal window
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";
EOF
chown site1:apache /var/www/site1/public/db-check.php
curl -fsS -H 'Host: site1.example.com' http://127.0.0.1/db-check.php | grep -x site1
rm /var/www/site1/public/db-check.php

A 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:

Terminal window
mkdir -p /var/www/server/public
echo "server.example.com" > /var/www/server/public/index.html
cat > /etc/httpd/conf.d/00-server.conf <<'EOF'
<VirtualHost *:80>
ServerName server.example.com
DocumentRoot /var/www/server/public
</VirtualHost>
EOF
systemctl reload httpd
dnf -y install mod_ssl python3-certbot-apache
certbot reconfigure --cert-name server.example.com --apache --non-interactive
certbot renew --cert-name server.example.com --dry-run

certbot 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:

Terminal window
certbot --apache -d site1.example.com --non-interactive --agree-tos -m postmaster@example.com --redirect
certbot --apache -d site2.example.com --non-interactive --agree-tos -m postmaster@example.com --redirect

certbot 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:

Terminal window
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 301

The 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:

Terminal window
dnf -y install pdns pdns-backend-sqlite sqlite bind-utils
sqlite3 /var/lib/pdns/pdns.sqlite3 < /usr/share/doc/pdns/schema.sqlite3.sql
chown -R pdns:pdns /var/lib/pdns
cat > /etc/pdns/pdns.conf <<'EOF'
launch=gsqlite3
gsqlite3-database=/var/lib/pdns/pdns.sqlite3
local-address=0.0.0.0, ::
setuid=pdns
setgid=pdns
zone-cache-refresh-interval=0
default-soa-content=ns1.@ hostmaster.@ 0 10800 3600 604800 3600
EOF
systemctl enable --now pdns
firewall-cmd --permanent --add-service=dns
firewall-cmd --reload

schema.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 with pdnsutil while the server runs answers REFUSED until 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 writes a.misconfigured.dns.server.invalid as 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:

Terminal window
pdnsutil zone create example.com
pdnsutil rrset add example.com example.com NS ns1.example.com.
pdnsutil rrset add example.com ns1.example.com A 203.0.113.10
pdnsutil rrset add example.com example.com A 203.0.113.10
pdnsutil rrset add example.com server.example.com A 203.0.113.10
pdnsutil rrset add example.com site1.example.com A 203.0.113.10
pdnsutil rrset add example.com site2.example.com A 203.0.113.10
pdnsutil 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.com

The 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:

Terminal window
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

ServicePortsSoftware
Websites, HTTPS, several PHP versions80, 443Apache, PHP-FPM 8.4 and 8.2 (Remi)
Databaseslocalhost onlyMariaDB
Receiving mail25Postfix
Sending mail (users)587, 465Postfix + Dovecot SASL
Reading mail993Dovecot
Spam filtering, DKIM—Rspamd
DNS53PowerDNS
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.

Terminal window
curl -fsSL https://get.corepanel.net/install.sh | bash

The installer runs on a server prepared with part 1, and CorePanel Personal is free. See Getting started.