Caddy + Drupal 7 on Rocky Linux 8

Let me introduce you guide about deployment of Drupal 7 on VDS/VPS with Caddy web-server. Assume that server has about 500 megabytes of RAM and Rocky Linux 8 is pre-installed (most likely, it will be the same under Alma or Oracle). In my opinion RHEL 9.x family is too "heavy" for such setup.

Since we are talking about VDS/VPS console commands most likely will be executed as root, so there will be a # sign in examples. If you are not root, then most or even all commands must be led by sudo.

OS preparations

Swap file

Usually you have not swap on VDS (check e.g. free -m). In that case create swap file of 512 megs:

# fallocate -l 512M /swapfile
# chmod 600 /swapfile
# mkswap /swapfile
# swapon /swapfile

After that you probably can install mc.

Add to /etc/fstab:

/swapfile none swap defaults 0 0

Add to /etc/sysctl.conf (supposedly to make it less "swappy"):

vm.swappiness=10

Reboot for good measure. And now you can dnf update.

fail2ban

# dnf install fail2ban

fail2ban-firewalld also "pulled" as dependency.

File /etc/fail2ban/jail.d/01-sshd.conf

[sshd]
enabled = true
bantime = 12h
maxretry = 3

Restart service:

# systemctl restart fail2ban

Check status:

# fail2ban-client status

firewalld

Check rules:

# firewall-cmd --list-all

Among other things we have e.g.:

services: cockpit dhcpv6-client ssh

Remove cockpit, add http and https:

# firewall-cmd --permanent --remove-service=cockpit
# firewall-cmd --permanent --add-service=http
# firewall-cmd --permanent --add-service=https
# firewall-cmd --reload

kdump

Usually enabled but not needed.

Chek status and disable:

# systemctl status kdump
# systemctl disable kdump

Check if core argument exists:

# dir /boot/vmlinuz*
# grubby --info /boot/vmlinuz-4.18.0-553.22.1.el8_10.x86_64 | grep args

(use actual file name from dir result). Output example:

args="ro crashkernel=auto rhgb quiet $tuned_params"

Remove:

# grubby --remove-args="crashkernel" --update=ALL

Didn't notice any particular difference though. I suspect when crashkernel=auto and RAM less than 500 megabytes system didn't reserve memory anyway.

MariaDB

Let's install not the newest version, for example 10.6. The more familiar install system preserved at mariadb.org. The new one is:

https://mariadb.com/docs/server/deploy/deployment-methods/repo/#Configur...

Why not?

# curl -LsSO https://r.mariadb.com/downloads/mariadb_repo_setup
# chmod +x mariadb_repo_setup
# ./mariadb_repo_setup --mariadb-server-version=10.6

https://mariadb.com/docs/server/deploy/topologies/single-node/community-...

Install:

# dnf install MariaDB-server

Enable:

# systemctl enable --now mysql

Secure installation:

# mariadb-secure-installation

Holy War: unix socket authentication vs root password. Personally I prefer socket for not add -u and -p in commands. That's why here are default answers except change the root password.

Enter current password for root (enter for none):
Switch to unix_socket authentication [Y/n]
Change the root password? [Y/n] n
Remove anonymous users? [Y/n]
Disallow root login remotely? [Y/n]
Remove test database and access to it? [Y/n]
Reload privilege tables now? [Y/n]

Go to MariaDB CLI:

# mysql

Check if Performance Schema is off by SHOW VARIABLES LIKE 'performance_schema'; query (you don't need it on 500 MB RAM):

MariaDB [(none)]> SHOW VARIABLES LIKE 'performance_schema';
+--------------------+-------+
| Variable_name      | Value |
+--------------------+-------+
| performance_schema | OFF   |
+--------------------+-------+

Create DB drupal, user drupal (password better be stronger) and grant privileges by series of queries:

CREATE DATABASE drupal;
CREATE USER drupal@localhost IDENTIFIED BY 'drupal_password';
GRANT ALL PRIVILEGES ON drupal.* to drupal@localhost;
FLUSH PRIVILEGES;

exit and restore dump (if you have one) (It's convenient with socket, isn't it?):

# zcat drupal.sql.gz | mysql drupal

PHP-FPM

https://docs.rockylinux.org/ru/guides/web/php/

epel-release is already installed somehow. Just in case:

# dnf install epel-release

Add remi:

# dnf install https://rpms.remirepo.net/enterprise/remi-release-8.rpm

Review available versions:

# dnf module list php

https://www.drupal.org/docs/7/system-requirements/php-requirements-for-d...

Drupal 7 is of course more like a story for the same 7th PHP, so it would be possible to get 7.2 (default) from AppStream. But current PHP version is 8.3 which is missing there. Depending on the project, minimal (or no) edits may be required. By the other hand, 8.1 or 8.2 recommended which are exists in AppStream. I will use 8.3 from remi to be clear.

# dnf module enable php:remi-8.3

Install php-fpm, php-cli (“just” php not needed because it also installs Apache) and minimal extensions pack.

# dnf install php-fpm php-cli php-gd php-xml php-mbstring php-mysqlnd

We'll deal with users and groups later. Meanwhile let's "rein in" workers pool.

In /etc/php-fpm.d/www.conf modify (decrease) settings e.g. like in Docker image:

pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

If this is not enough, you can adjust the following settings in /etc/php-fpm.conf e.g:

emergency_restart_threshold = 10
emergency_restart_interval = 1m
process_control_timeout = 10s

Turn service on:

# systemctl enable --now php-fpm

Caddy

https://docs.rockylinux.org/ru/guides/web/caddy/

Since we have EPEL, install Caddy from it:

# dnf install caddy

To be fair, Caddy is not the newest there. Oh well...

Enable and start service:

# systemctl enable --now caddy

Let's see if server is responding.

HTTP Server Test Page

Now if we analyze service definition, we'll see that Caddy runs under the same-named user, while php-fpm under apache. Replace nginx into caddy in listen.acl_users setting of /etc/php-fpm.d/www.conf file:

listen.acl_users = apache,caddy

It is not necessary at this stage, but if you wish:

# systemctl restart php-fpm

Drupal

Quick check

Place sources into /var/www/html and change owner to apache (according to php-fpm).

# chown -R apache:apache /var/www/html

Because of 755 / 644 default permissions web-server (caddy user) can also read the files. Change sites/default/settings.php if necessary.

Next step is to edit main Caddy configuration (/etc/caddy/Caddyfile) or create new file in /etc/caddy/Caddyfile.d. In the second case when the server responds by IP or unknown host a dummy page will shown, which is probably wrong.

So, nominally (ATTENTION! NOT SAFE!) edit Caddyfile:

# The Caddyfile is an easy way to configure your Caddy web server.
# https://caddyserver.com/docs/caddyfile

# The configuration below serves a welcome page over HTTP on port 80.  To use
# your own domain name with automatic HTTPS, ensure your A/AAAA DNS record is
# pointing to this machine's public IP, then replace `http://` with your domain
# name.  Refer to the documentation for full instructions on the address
# specification.
#
# https://caddyserver.com/docs/caddyfile/concepts#addresses
http:// {

    # Set this path to your site's directory.
    #root * /usr/share/caddy
    root * /var/www/html

    # Enable the static file server.
    file_server

    # Another common task is to set up a reverse proxy:
    # reverse_proxy localhost:8080

    # Or serve a PHP site through php-fpm:
    # php_fastcgi localhost:9000
    php_fastcgi unix//run/php-fpm/www.sock

    # Refer to the directive documentation for more options.
    # https://caddyserver.com/docs/caddyfile/directives

}

# As an alternative to editing the above site block, you can add your own site
# block files in the Caddyfile.d directory, and they will be included as long
# as they use the .caddyfile extension.
import Caddyfile.d/*.caddyfile

Restart services (by the way one can caddy reload instead of systemctl restart caddy) and check the site. Basically it should work, but we'll deal with the little things later.

Write permissions

We must provide write permissions to sites/default/files. Remember that we have Rocky Linux and therefore SELinux enabled (sestatus to check).

[root@vm876632 ~]# sestatus
SELinux status:                 enabled
SELinuxfs mount:                /sys/fs/selinux
SELinux root directory:         /etc/selinux
Loaded policy name:             targeted
Current mode:                   enforcing
Mode from config file:          enforcing
Policy MLS status:              enabled
Policy deny_unknown status:     allowed
Memory protection checking:     actual (secure)
Max kernel policy version:      33
[root@vm876632 ~]# cd /var/www/html/sites/default/files
[root@vm876632 files]# ls -Z
unconfined_u:object_r:httpd_sys_content_t:s0 bootstrap
unconfined_u:object_r:httpd_sys_content_t:s0 css
unconfined_u:object_r:httpd_sys_content_t:s0 css_injector
unconfined_u:object_r:httpd_sys_content_t:s0 ctools
unconfined_u:object_r:httpd_sys_content_t:s0 imagecache
unconfined_u:object_r:httpd_sys_content_t:s0 imagecache_sample.png
unconfined_u:object_r:httpd_sys_content_t:s0 imagefield_default_images
unconfined_u:object_r:httpd_sys_content_t:s0 imagefield_thumbs
unconfined_u:object_r:httpd_sys_content_t:s0 images
unconfined_u:object_r:httpd_sys_content_t:s0 imports
unconfined_u:object_r:httpd_sys_content_t:s0 js
unconfined_u:object_r:httpd_sys_content_t:s0 languages
unconfined_u:object_r:httpd_sys_content_t:s0 module
unconfined_u:object_r:httpd_sys_content_t:s0 products
unconfined_u:object_r:httpd_sys_content_t:s0 styles
unconfined_u:object_r:httpd_sys_content_t:s0 xmlsitemap

As we can see context is httpd_sys_content_t while httpd_sys_rw_content_t required.

In theory fail2ban pulled in policycoreutils-python-utils package, from which we need semanage and restorecon commands. Hence:

# semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/html/sites/default/files(/.*)?"

Set the same context also on sites/all directory for the update features to work:

# semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/html/sites/all(/.*)?"

Restore (apply) context:

# restorecon -r /var/www/html/sites

Just in case check this SELinux setting:

# getsebool httpd_can_network_connect

If it is disabled, Drupal's auto update features most likely will not operate. Enable (-P - permanent):

# setsebool -P httpd_can_network_connect on

PHP configuration

We didn't touch /etc/php.ini but it would be desirable to correct some things here. According to the same requirements page make this changes:

expose_php = off
allow_url_fopen = off

Other listed parameters are missing or have recommended values.

If you wish you can manage memory limit and POST size among with count and size of uploading files (below are settings from php.ini).

memory_limit = 128M
post_max_size = 8M
upload_max_filesize = 2M
max_file_uploads = 20

After making changes in configuration restart of php-fpm required.

Caddy configuration

Since index.php of Drupal 7 located straight in the root directory, to avoid any troubles a huge .htaccess file (for Apache) provided. From this point of view of course it's better to use Drupal among with Apache, but now we need to reproduce this whole thing for Caddy.

First important fragment:

# Protect files and directories from prying eyes.
<FilesMatch "\.(engine|inc|info|install|make|module|profile|test|po|sh|.*sql|theme|tpl(\.php)?|xtmpl)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^(\.(?!well-known).*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
  <IfModule !mod_authz_core.c>
    Order allow,deny
  </IfModule>
</FilesMatch>

The fiercest regular expression ever. One can use regex101.com and still won't understand anything about it (actually it checks different file extensions or some files and directories in the root folder). More over, this expression is not compatible with RE2 language used by Caddy - (?!well-known) allowed in PCRE, but not in RE2. Point is that the file (path) starts with a dot except .well-known. It looks like we don't use such URLs (yet?), so I suggest just exclude incompatible piece from the expression. We also have to add / (escaping is not required for Caddy) when the expression compares beginning of a string.

https://caddyserver.com/docs/caddyfile/matchers#path-regexp

    # Protect files and directories from prying eyes.
    @protect_files {
        path_regexp \.(engine|inc|info|install|make|module|profile|test|po|sh|.*sql|theme|tpl(\.php)?|xtmpl)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^/(\..*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^/#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$
    }
    error @protect_files 403

Honestly I would include additional forbiddance of .php files (they sometimes come across outside of root directory).

    # Deny access to .php files outside root directory
    @outer_php {
        path_regexp ^/.*/.*\.php$
    }
    error @outer_php 403

To be fair directives file_server and php_fastcgi did almost everything else. One can bring "bucket of compression":

    encode gzip

Is it worth “converting” mod_expires? Seems to be the similar not path_regexp \.php$ does not works correctly (a double Cache-Control header appears in documents themselves). One can list static files extensions explicit:

https://caddy.community/t/correct-way-to-set-expires-on-caddy-2/7914/8

    # Cache static files for 2 weeks after access
    @static {
        path_regexp \.(ico|css|js|gif|jpg|jpeg|png|svg|woff)$
    }
    header @static Cache-Control max-age=1209600

But the documentation suggests another way which I'll keep:

    header ?Cache-Control "max-age=1209600"

Meaning add this header if "backend" doesn't set it. And for documents (pages) it is defined by Drupal, which is exactly what we need. To be honest I would reduce time from 2 weeks to 1-2 hours, but it depends on the traffik and frequency of site updates.

X-Content-Type-Options nosniff header seems to be already exists, about unset Proxy…

    header -Proxy

But how can we check? Also Server can be excluded (so no one would guess that we have Caddy):

    header -Server

Final configuration:

http:// {
    # Set this path to your site's directory.
    root * /var/www/html

    header -Proxy
    header -Server
    header ?Cache-Control "max-age=1209600"

    # Protect files and directories from prying eyes.
    @protect_files {
        path_regexp \.(engine|inc|info|install|make|module|profile|test|po|sh|.*sql|theme|tpl(\.php)?|xtmpl)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^/(\..*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^/#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$
    }
    error @protect_files 403

    # Deny acces to .php files outside root directory
    @outer_php {
        path_regexp ^/.*/.*\.php$
    }
    error @outer_php 403

    # Enable the static file server.
    encode gzip
    file_server

    # Serve a PHP site through php-fpm
    php_fastcgi unix//run/php-fpm/www.sock
}

Let’s Encrypt

After all reviews one can use auto https feature. To do this, add global block specifying your email address to the beginning of /etc/caddy/Caddyfile:

{
    email admin@example.com
}

And then instead of http:// indicate the website domain. That's it :)

API

By default Caddy has the API on 2019 port. Thanks to this, in particular, caddy reload works. As we have firewalld running, in theory we can leave everything as is (the port is not accessible from outside). But if we haven't, then it's better to switch API to listening to unix socket or disable at all.

In global options block to listen socket:

    admin unix//run/caddy-admin.sock

To disable:

    admin off

But in this case, after changing the configuration, exactly the service must be restarted.

OPcache

Let's see if we can boost performance.

Install extension:

# dnf install php-opcache

The Rocky Linux documentation recommends adjusting the settings in /etc/php.d/10-opcache.ini: memory consupmtion - overall and for buffer of whatever-this-means "interned" strings, and count of accelerated files. The last value is suggested to be matched with total of php-files, however Drupal specific structure interferes them form being counted. Here's the defaults:

opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000

I would reduce first parameter as we have very limited RAM.

To be honest, it doesn't feel like something has been accelerated - the DBMS stills primary "brake". One can go further and enable JIT - to do this set up size of the corresponding buffer (e.g. 8 megabytes):

; The amount of shared memory to reserve for compiled JIT code. A zero value disables the JIT.
; When an int is used, the value is measured in bytes. Shorthand notation may also be used. 
opcache.jit_buffer_size=8M

But after that the site stops working, Just in case I kept it for descendants.

systemctl restart php-fpm as usual after changing of configuration.

Access through FTP

Probably upload updates of Drupal itself or some edits by FTP is more comfortable. Install "standard" server ProFTPD (from EPEL):

# dnf install proftpd proftpd-utils

Create FTP-user www-data associated to system user apache (to confuse them all). But first let's find out uid and gid of the system user - it's 48.

[root@vm876632 ~]# cat /etc/passwd | grep apache
apache:x:48:48:Apache:/usr/share/httpd:/sbin/nologin

Substitute according values into command:

# ftpasswd --passwd --file=/etc/proftpd/ftpd.passwd --name=www-data --uid=48 --gid=48 --home=/var/www/html --shell=/bin/false

Since you specified the --passwd parameter, you will be prompted to enter a password.

Edit main /etc/proftpd.conf file.

Remove (or comment) existing parameters:

  • AuthPAMConfig
  • AuthOrder

Add "nearby":

AuthUserFile /etc/proftpd/ftpd.passwd
AuthPAM off
AuthOrder mod_auth_file.c

This switches the authentication mode to get users from file. Add into <Global> section:

  RequireValidShell off

We specified "wrong" shell /bin/false above, so now we have to make it available.

Add FTP to firewall:

# firewall-cmd --permanent --add-service=ftp
# firewall-cmd --reload

Grant file access at SELinux level for FTP service:

# setsebool -P ftpd_full_access on

Only after that one can go on:

# systemctl enable --now proftpd

It would be good to set up passive mode, but we've already messed around with FTP. So hell with it. :)

Conclusion

In theory one can live on 500 megs of RAM. If a page is available in the Drupal cache then it's "spit out" quite fast. "On cold" or when logged in it is about second “±mile”.

Further optimization steps are using more lightweight OS (for example popular in the Docker environment Alpine Linux) or migrate Drupal to SQLite. Or not to Drupal. :)