# 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.

Source: https://www.corepanel.net/tutorials/perfect-server-almalinux-rocky
Author: CorePanel Team
Published: 2026-10-05
Track: Server stack · advanced · ~150 min
Tested on: AlmaLinux 9.8 (2026-10-05)
Tested on: AlmaLinux 10.2 (2026-10-05)
Tested on: Rocky Linux 9.8 (2026-10-05)
Tested on: Rocky Linux 10.2 (2026-10-05)
Before you start: https://www.corepanel.net/tutorials/initial-server-setup-almalinux-rocky, https://www.corepanel.net/tutorials/mail-server-postfix-dovecot-rspamd-almalinux-rocky, https://www.corepanel.net/tutorials/multiple-php-versions-almalinux-rocky
CorePanel edition needed: Personal (free)
Tags: perfect-server, apache, php-fpm, mariadb, postfix, dovecot, powerdns, almalinux, rocky-linux
From CorePanel Tutorials — https://www.corepanel.net/tutorials

---

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](https://www.corepanel.net/tutorials/initial-server-setup-almalinux-rocky)** — updates,
   hostname, time, firewall, automatic security updates, key-only SSH.
2. **[Mail server](https://www.corepanel.net/tutorials/mail-server-postfix-dovecot-rspamd-almalinux-rocky)** —
   Postfix, Dovecot, Rspamd and DKIM, with a Let's Encrypt certificate.
3. **[Websites on several PHP versions](https://www.corepanel.net/tutorials/multiple-php-versions-almalinux-rocky)**
   — 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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.

```bash
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](https://www.corepanel.net/docs/getting-started).
