Install Craft CMS with LEMP Stack on Rocky Linux 8 by combining Nginx, PHP-FPM, MySQL 8.4, and Composer on a clean VPS. You will finish with a TLS-protected Craft control panel and a public web/ document root in about 15–20 minutes, assuming DNS already points to the server.
Prerequisites
- Rocky Linux 8.10, fully updated, with 2 vCPUs, at least 2 GB of RAM, and 10 GB of free disk space.
- A root account or sudo-enabled user connected over SSH. The commands below use
sudo. - A domain such as
YOUR_DOMAINwith an A record pointing to the VPS. Ports 80 and 443 must be reachable during certificate issuance. - A unique MySQL password, a Craft administrator password, and an email address for Let’s Encrypt notices.
- A VPSLake Linux VPS with a public IPv4 address is suitable for this LEMP stack.
- Do not run Apache or another service on ports 80 and 443; Nginx will own those ports.
Craft 5 needs PHP 8.2 or newer, Composer 2, and MySQL 8.0.17 or newer. This guide uses PHP 8.3 from Remi and MySQL 8.4 LTS; review Craft’s official requirements if you plan to add demanding plugins or a large content library.
Step 1: Install the LEMP packages
Rocky Linux 8’s default PHP stream can be too old for a new Craft project, so enable the Remi PHP 8.3 stream and MySQL’s EL8 repository before installing the services.
sudo dnf update -y
sudo dnf install -y epel-release firewalld nginx curl unzip tar policycoreutils-python-utils
sudo dnf module disable -y mysql
sudo dnf install -y https://dev.mysql.com/get/mysql84-community-release-el8-3.noarch.rpm
sudo dnf install -y mysql-community-server
sudo dnf install -y https://rpms.remirepo.net/enterprise/remi-release-8.rpm
sudo dnf module reset -y php
sudo dnf module enable -y php:remi-8.3
sudo dnf install -y php php-bcmath php-cli php-common php-curl php-fpm php-gd php-intl php-mbstring php-mysqlnd php-opcache php-process php-xml php-zip
sudo sed -ri 's/^[; ]*user = .*/user = nginx/; s/^[; ]*group = .*/group = nginx/; s/^[; ]*listen.owner = .*/listen.owner = nginx/; s/^[; ]*listen.group = .*/listen.group = nginx/' /etc/php-fpm.d/www.conf
sudo sed -ri 's/^memory_limit = .*/memory_limit = 512M/; s/^upload_max_filesize = .*/upload_max_filesize = 64M/; s/^post_max_size = .*/post_max_size = 64M/; s/^max_execution_time = .*/max_execution_time = 120/' /etc/php.ini
sudo systemctl enable --now firewalld mysqld nginx php-fpm
The PHP-FPM pool runs as nginx, while MySQL remains bound for local application use. Check the versions before continuing:
php -v
mysql --version
nginx -v
PHP 8.3.x (cli)
mysql Ver 8.4.x
nginx version: nginx/1.x
Run the interactive hardening wizard once, because a fresh MySQL installation has a temporary root password and default settings that should not remain on an internet-facing VPS.
sudo mysql_secure_installation
Choose a strong root password, remove anonymous users, disable remote root login, remove the test database, and reload the privilege tables when prompted. You will use a separate application account in the next step.
Step 2: Create a private MySQL database
Craft needs a database account with schema privileges, but it does not need MySQL exposed to the public network.
sudo mysql -u root -p
At the mysql> prompt, run the following SQL. Replace YOUR_DB_PASSWORD with a long, unique password before submitting it.
CREATE DATABASE craftcms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'craft_user'@'localhost' IDENTIFIED BY 'YOUR_DB_PASSWORD';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, LOCK TABLES ON craftcms.* TO 'craft_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Confirm that the schema exists without opening port 3306:
sudo mysql -u root -p -e "SHOW DATABASES LIKE 'craftcms';"
Database (craftcms)
craftcms
Step 3: Install Composer and create the Craft project
The Composer starter creates the current Craft application layout. Keeping the project above Nginx’s public root prevents visitors from requesting .env, config, or vendor files.
Install Composer 2 with its published checksum, then create the project at /var/www/craft:
set -eu
sudo curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php
EXPECTED_CHECKSUM="$(curl -fsSL https://composer.github.io/installer.sig)"
ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', '/tmp/composer-setup.php');")"
test "$EXPECTED_CHECKSUM" = "$ACTUAL_CHECKSUM"
sudo php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm -f /tmp/composer-setup.php
sudo env COMPOSER_ALLOW_SUPERUSER=1 composer create-project craftcms/craft /var/www/craft --no-scripts
Create the environment file and the directories Craft must write during installation. Replace both placeholders in this block with the values you chose earlier.
sudo install -d -o root -g nginx -m 0770 /var/www/craft/storage /var/www/craft/config/project /var/www/craft/web/cpresources
sudo install -o root -g nginx -m 0660 /dev/null /var/www/craft/.env
sudo tee /var/www/craft/.env > /dev/null <<'ENV'
CRAFT_APP_ID=
CRAFT_SECURITY_KEY=
CRAFT_ENVIRONMENT=production
PRIMARY_SITE_URL="https://YOUR_DOMAIN/"
CRAFT_DB_DRIVER=mysql
CRAFT_DB_SERVER=localhost
CRAFT_DB_PORT=3306
CRAFT_DB_DATABASE=craftcms
CRAFT_DB_USER=craft_user
CRAFT_DB_PASSWORD="YOUR_DB_PASSWORD"
CRAFT_DB_SCHEMA=
CRAFT_DB_TABLE_PREFIX=
ENV
sudo chown -R root:nginx /var/www/craft
sudo find /var/www/craft -type d -exec chmod 0750 {} \;
sudo find /var/www/craft -type f -exec chmod 0640 {} \;
sudo chmod 0660 /var/www/craft/.env
sudo chmod 0770 /var/www/craft/config /var/www/craft/config/project /var/www/craft/storage /var/www/craft/web/cpresources
Replace YOUR_DOMAIN and YOUR_DB_PASSWORD; do not leave either placeholder in the live file. Generate Craft’s application ID and security key as the same account that PHP-FPM uses:
cd /var/www/craft
sudo -u nginx env HOME=/var/lib/nginx php craft setup/app-id
sudo -u nginx env HOME=/var/lib/nginx php craft setup/security-key
Each command should finish successfully and write its value into .env. These permissions avoid world-writable files and leave SELinux enabled.
Step 4: Apply SELinux labels for Craft
SELinux permits Nginx to read the project by default, but Craft needs write access to its storage, project configuration, and control-panel resources.
sudo semanage fcontext -a -t httpd_sys_content_t '/var/www/craft(/.*)?'
sudo semanage fcontext -a -t httpd_sys_rw_content_t '/var/www/craft/(config|storage|web/cpresources)(/.*)?'
sudo restorecon -Rv /var/www/craft
The final command should list restored paths without AVC denial errors. The writable context applies only to the directories Craft documents as runtime storage.
Step 5: Configure Nginx for the Craft web root
Craft’s front controller handles clean URLs, so Nginx must serve /var/www/craft/web and route unknown paths to index.php.
sudo tee /etc/nginx/conf.d/craft.conf > /dev/null <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name YOUR_DOMAIN;
root /var/www/craft/web;
index index.php;
client_max_body_size 64m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param HTTP_PROXY "";
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
location ~ /\.(?!well-known) {
deny all;
}
}
NGINX
sudo nginx -t
sudo systemctl reload nginx
Change YOUR_DOMAIN before running the block. The configuration test should report syntax is ok and test is successful; Nginx will not reload an invalid file.
Step 6: Open web ports and enable HTTPS
Allow SSH plus the two web services through Rocky’s firewalld, then let Certbot add the TLS configuration and redirect HTTP traffic.
sudo firewall-cmd --permanent --zone=public --add-service=ssh
sudo firewall-cmd --permanent --zone=public --add-service=http
sudo firewall-cmd --permanent --zone=public --add-service=https
sudo firewall-cmd --reload
sudo dnf install -y certbot python3-certbot-nginx
sudo certbot --nginx --redirect --agree-tos --no-eff-email -m YOUR_EMAIL -d YOUR_DOMAIN
Replace YOUR_EMAIL with an address you monitor. The domain must already resolve to this server, and port 80 must be reachable while Certbot validates it. A successful run reports the certificate location and an HTTPS redirect.
Step 7: Run the Craft web installer
The browser installer completes the database migrations and creates the first administrator after the server-side project is ready.
Open https://YOUR_DOMAIN/admin/install and enter these database values if Craft asks for them:
- Database:
craftcms - User:
craft_user - Server:
localhost - Port:
3306 - Password: the value used for
YOUR_DB_PASSWORD
Create a separate administrator password and store it in a password manager. For the reasoning behind a separate web/ document root and the Nginx pattern above, see Craft’s server setup guidance.
Verify the Craft installation
Check the service states, PHP extensions, Nginx syntax, Craft installation status, and HTTPS response from SSH. Then sign in at https://YOUR_DOMAIN/admin and load the site’s front page.
sudo systemctl is-active nginx php-fpm mysqld firewalld
sudo nginx -t
php -m | grep -E 'bcmath|curl|gd|intl|mbstring|mysqli|PDO|xml|zip'
cd /var/www/craft
sudo -u nginx env HOME=/var/lib/nginx php craft install/check
curl -fsSI https://YOUR_DOMAIN/
active
active
active
active
syntax is ok
test is successful
HTTP/2 200
The extension list can appear in a different order, and the first request may redirect to the control panel. A successful Craft login and a rendered front page confirm the complete stack.
Troubleshooting
502 Bad Gateway
Nginx cannot reach PHP-FPM when the service is stopped or the pool socket is not readable by Nginx. Check the socket and restart both services:
sudo systemctl status php-fpm --no-pager
sudo ls -l /run/php-fpm/www.sock
sudo systemctl restart php-fpm nginx
If the socket path differs, use the listen = value in /etc/php-fpm.d/www.conf in the fastcgi_pass directive.
SQLSTATE[HY000] [1045] Access denied
The password in .env does not match the craft_user account, or the account was created for a different host. Run sudo mysql -u root -p -e "SELECT User,Host FROM mysql.user WHERE User='craft_user';"; the host should be localhost, then update the password in .env to match.
403 Forbidden or permission denied during setup
Check the Unix group and SELinux labels instead of loosening permissions. Run sudo restorecon -Rv /var/www/craft, confirm that storage, config/project, and web/cpresources are group-writable by nginx, and verify that Nginx’s root is exactly /var/www/craft/web.
Certbot says the challenge failed
The DNS record may still point elsewhere, or an upstream VPS firewall may block port 80. Confirm dig +short YOUR_DOMAIN returns the VPS address, allow HTTP in the VPS control panel, and run Certbot again after DNS propagation.
Hardening and maintenance
- Keep MySQL private: do not add a firewalld rule for port 3306, and use an SSH tunnel for remote database administration.
- Back up the database,
storage, and uploaded assets before Craft or Composer updates. - Test renewal with
sudo certbot renew --dry-runand keep the renewal timer enabled. - Update the host with
sudo dnf update -y; review Craft and plugin changes before applying them to production.
FAQ
Can I use an IP address instead of a domain?
You can use the VPS IP for an initial test, but a domain is the right choice for a public Craft site. The HTTPS step relies on DNS validation, and PRIMARY_SITE_URL should match the hostname visitors will use.
Why does this guide use MySQL instead of MariaDB?
Both database families meet Craft’s minimum requirements, but Craft currently recommends MySQL for sites with many users or a large amount of content. MySQL 8.4 also gives this Rocky Linux 8 setup a maintained LTS series with an official EL8 repository.
Which Craft files need write access?
At runtime, PHP needs to write to storage, config/license.key, and web/cpresources. The guide grants the Nginx group access to those locations while keeping the rest of the project outside the public root.
How should I update Craft later?
Take a database and file backup, run Composer from /var/www/craft, and use php craft up to apply migrations and project configuration changes. Perform updates in a maintenance window and check plugin compatibility before upgrading the CMS core.