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