This guide walks you through setting up a production-ready Magento 2.4.8 (Open Source / Community Edition) on a VPS from scratch. We’re using Ubuntu 24.04 LTS, a hybrid architecture with host-based Nginx and PHP alongside Docker for supporting services, Varnish for full page caching, and Cloudflare for DNS, SSL, and edge protection.
Every step includes sanity checks so you can confirm things are working before moving on. If something breaks at Step 10, you’ll know it isn’t a problem from Step 3.
This guide assumes you’re comfortable with the Linux command line — editing files, managing services, and navigating a terminal. You don’t need to be a Magento expert, but you should be willing to dig into the docs for services like OpenSearch or RabbitMQ if you want to understand them beyond what we cover here.
Before you begin, make sure you have the following ready:
Before we start installing anything, let’s understand what we’re building and why each piece is there.
When a customer visits your store, their request travels through several layers:
Customer → Cloudflare (DNS + SSL + WAF)
→ Nginx on port 443 (terminates SSL, proxies to Varnish)
→ Varnish on port 6081 (serves cached pages or passes through)
→ Nginx on port 8080 (handles PHP routing)
→ PHP-FPM (executes Magento code)
→ Magento application
Behind the scenes, Magento talks to several supporting services, all running in Docker:
You’ll find guides that run everything in Docker, and others that install everything directly on the host. We’re doing a deliberate split, and here’s why.
Nginx and PHP-FPM run on the host because Magento is PHP-heavy and benefits from direct filesystem access. Running PHP inside Docker adds a layer of overhead — shared volumes are slower, and you’re adding complexity to something that needs to be fast and easy to debug. When something goes wrong with Magento (and something will), you want php bin/magento to just work without thinking about container contexts.
MariaDB, OpenSearch, Redis, and RabbitMQ run in Docker because these are stateful services that benefit from isolation. Docker makes it trivial to upgrade MariaDB from 10.6 to 11.4, or swap OpenSearch versions, without risking your host system. Each service gets its own container with its own configuration, and persistent data lives in Docker volumes that survive container rebuilds.
Varnish also runs in Docker — it sits between the two Nginx layers and doesn’t need filesystem access to Magento. Running it in a container keeps it cleanly separated and easy to manage.
This split gives you the performance of a bare-metal PHP setup with the operational convenience of Docker for everything else.
Magento is not lightweight. Don’t try to run a production store on a $5/month VPS — you’ll spend more time debugging performance issues than you saved on hosting.
For a small to mid-size store (a few thousand products, moderate traffic):
For a serious production store (large catalog, higher traffic, multiple store views):
On Hetzner, look for instances with dedicated CPU cores and at least 16 GB RAM as a starting point. Other providers like DigitalOcean, Vultr, or Linode have equivalent tiers — the key is dedicated CPU cores and NVMe storage. Shared CPU instances will struggle under Magento’s indexing and compilation processes.
At this point, confirm you have:
If any of these are missing, sort them out now. Everything from here assumes these are in place.
Spin up a new server with your VPS provider. On Hetzner:
Once the server is created, note the public IP address. You’ll need it shortly.
SSH into your server as root:
ssh root@YOUR_SERVER_IPCode language: CSS (css)
Don’t run everything as root. Create a dedicated user:
adduser magento
usermod -aG sudo magento
Add the magento user to the www-data and adm groups so it can read application and Nginx logs without sudo — useful for debugging via SSH or SFTP:
usermod -aG www-data magento
usermod -aG adm magento
Add the magento user to the www-data and adm groups so it can read Magento application logs and Nginx logs without sudo:
usermod -aG www-data magento
usermod -aG adm magento
Copy your SSH key to the new user so you can log in directly:
mkdir -p /home/magento/.ssh
cp /root/.ssh/authorized_keys /home/magento/.ssh/
chown -R magento:magento /home/magento/.ssh
chmod 700 /home/magento/.ssh
chmod 600 /home/magento/.ssh/authorized_keys
Test the login in a new terminal window before proceeding (don’t close your root session yet):
ssh magento@YOUR_SERVER_IPCode language: CSS (css)
Once you’ve confirmed the new user can log in and use sudo, lock down root access:
sudo nano /etc/ssh/sshd_config
Find and change (or add) these lines:
PermitRootLogin no
PasswordAuthentication no
Restart SSH:
sudo systemctl restart ssh
Important: Make sure you’ve tested your
magentouser login before doing this. Locking yourself out of a remote server is not fun.
UFW (Uncomplicated Firewall) is included with Ubuntu. Set it up to allow only what we need:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp <em># SSH</em>
sudo ufw allow 80/tcp <em># HTTP (for Cloudflare / Let's Encrypt challenges)</em>
sudo ufw allow 443/tcp <em># HTTPS</em>
sudo ufw enableCode language: HTML, XML (xml)
Check the status:
sudo ufw status verbose
You should see your three rules listed and the firewall active.
Note: We’re not exposing any Docker service ports (MariaDB, Redis, OpenSearch, RabbitMQ) to the outside world. They’ll be bound to 127.0.0.1 only. The firewall is an additional safety layer.
Fail2Ban monitors log files and bans IPs that show malicious behaviour (like brute-force SSH attempts):
sudo apt update
sudo apt install -y fail2ban
Create a local configuration file (so your settings survive package updates):
sudo nano /etc/fail2ban/jail.local
Add:
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
[sshd]
enabled = true
port = 22
filter = sshd
logpath = /var/log/auth.logCode language: PHP (php)
Start and enable Fail2Ban:
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
Check that it’s running:
sudo fail2ban-client status sshd
You should see the jail is active with 0 currently banned IPs (unless someone’s already poking at your server).
Set your server timezone. UTC is a common choice for servers:
sudo timedatectl set-timezone UTCCode language: JavaScript (javascript)
Ensure time synchronisation is active:
timedatectl status
Look for NTP service: active in the output. Ubuntu 24.04 uses systemd-timesyncd by default, which handles this automatically. If it’s not active:
sudo timedatectl set-ntp onCode language: JavaScript (javascript)
We’ll use Docker’s official repository rather than Ubuntu’s packaged version, which tends to lag behind.
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt updateCode language: PHP (php)
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginCode language: CSS (css)
Add your user to the docker group so you don’t need sudo for every Docker command:
sudo usermod -aG docker magento
Log out and log back in for the group change to take effect, then verify:
docker --version
docker compose version
Both commands should return version numbers without errors.
By default, Docker bypasses UFW rules by manipulating iptables directly. This is important — if you expose a port in a Docker container (e.g., -p 3306:3306), that port is accessible from the internet even if UFW doesn’t allow it. This is why we’ll bind all Docker services to 127.0.0.1 explicitly in our docker-compose.yml. Never use -p 3306:3306 when you mean -p 127.0.0.1:3306:3306.
Run through these checks before moving on:
<em># Confirm you're logged in as your sudo user, not root</em>
whoami
<em># Expected: magento</em>
<em># Confirm UFW is active</em>
sudo ufw status
<em># Expected: Status: active, with rules for 22, 80, 443</em>
<em># Confirm Fail2Ban is running</em>
sudo fail2ban-client status sshd
<em># Expected: Status for the jail: sshd, with actions listed</em>
<em># Confirm Docker is working</em>
docker run --rm hello-world
<em># Expected: "Hello from Docker!" message</em>
<em># Confirm Docker Compose is available</em>
docker compose version
<em># Expected: Docker Compose version v5.x.x or later</em>
<em># Confirm time sync</em>
timedatectl status
<em># Expected: NTP service: active</em>Code language: HTML, XML (xml)
If all of these pass, your server is provisioned, secured, and ready for the Docker infrastructure setup in Step 2.
In this step we’ll spin up all the supporting services Magento needs: MariaDB, OpenSearch, Redis (two instances), RabbitMQ, and Varnish. Everything runs in Docker, managed by a single docker-compose.yml file.
Here’s what each service does and why Magento needs it:
Create a directory for your Docker configuration in your home folder:
mkdir -p ~/docker
cd ~/docker
Create the docker-compose.yml file:
nano ~/docker/docker-compose.ymlCode language: JavaScript (javascript)
Paste the following:
services:
mariadb:
image: mariadb:10.6
container_name: magento-mariadb
restart: unless-stopped
ports:
- "127.0.0.1:3306:3306"
environment:
MYSQL_ROOT_PASSWORD: your_root_password_here
MYSQL_DATABASE: magento
MYSQL_USER: magento
MYSQL_PASSWORD: your_magento_db_password_here
volumes:
- mariadb_data:/var/lib/mysql
- ./mariadb/my.cnf:/etc/mysql/conf.d/magento.cnf:ro
mem_limit: 2g
opensearch:
image: opensearchproject/opensearch:2.19.1
container_name: magento-opensearch
restart: unless-stopped
ports:
- "127.0.0.1:9200:9200"
environment:
- discovery.type=single-node
- plugins.security.disabled=true
- OPENSEARCH_JAVA_OPTS=-Xms768m -Xmx768m
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=YourStrongAdminPass1!
volumes:
- opensearch_data:/usr/share/opensearch/data
mem_limit: 2048m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
redis-cache:
image: redis:7.2-alpine
container_name: magento-redis-cache
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru --save ""
volumes:
- redis_cache_data:/data
mem_limit: 768m
redis-session:
image: redis:7.2-alpine
container_name: magento-redis-session
restart: unless-stopped
ports:
- "127.0.0.1:6380:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly yes
volumes:
- redis_session_data:/data
mem_limit: 512m
rabbitmq:
image: rabbitmq:3.13-management-alpine
container_name: magento-rabbitmq
restart: unless-stopped
ports:
- "127.0.0.1:5672:5672"
- "127.0.0.1:15672:15672"
environment:
RABBITMQ_DEFAULT_USER: magento
RABBITMQ_DEFAULT_PASS: your_rabbitmq_password_here
volumes:
- rabbitmq_data:/var/lib/rabbitmq
mem_limit: 512m
varnish:
image: varnish:7.6
container_name: magento-varnish
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "127.0.0.1:6081:80"
volumes:
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
tmpfs:
- /var/lib/varnish/varnishd:exec
environment:
VARNISH_SIZE: 512m
mem_limit: 768m
volumes:
mariadb_data:
opensearch_data:
redis_cache_data:
redis_session_data:
rabbitmq_data:Code language: PHP (php)
Let’s walk through the important decisions in this file:
Every port is bound to 127.0.0.1. As we discussed in Step 1, Docker bypasses UFW. Binding to 127.0.0.1 ensures these services are only reachable from the server itself — not the internet. This is non-negotiable for production.
restart: unless-stopped means containers come back after a server reboot, but stay down if you explicitly stop them. This is the right policy for production services.
mem_limit on each container prevents any single service from consuming all available RAM and starving the others. Adjust these values based on your server’s total RAM — the values above are tuned for a 16 GB server. Here’s the full memory budget:
| Service | Container mem_limit | Key internal setting | Notes |
|---|---|---|---|
| MariaDB | 2 GB | innodb_buffer_pool_size = 1G | Biggest consumer — InnoDB buffer pool is ~50% of limit |
| OpenSearch | 2 GB | JVM heap 768 MB | Needs headroom beyond JVM for file caches during reindexing |
| Redis Cache | 768 MB | maxmemory 512mb | Disposable data — no persistence |
| Redis Sessions | 512 MB | maxmemory 256mb | Persistent (AOF enabled) |
| RabbitMQ | 512 MB | Default | Lightweight for Magento’s queue workload |
| Varnish | 768 MB | VARNISH_SIZE 512m | Cache size + overhead |
| Docker total | ~6.5 GB | ||
| PHP-FPM (host) | ~3–4 GB | Depends on pool size | Each worker can use 256–512 MB |
| Nginx + OS (host) | ~1–1.5 GB | ||
| Headroom | ~4.5–5.5 GB | For indexing spikes, Composer, etc. |
If you’re on a 32 GB server, you can double most of these — especially innodb_buffer_pool_size, OpenSearch JVM heap, and the Varnish cache size.
Important: Replace the placeholder passwords (
your_root_password_here,your_magento_db_password_here,your_rabbitmq_password_here) with strong, unique passwords. Use something likeopenssl rand -base64 24 | tr -d '/+='to generate them. Avoid passwords containing$characters — Docker Compose interprets$as a variable reference, which will cause warnings or break your credentials. If you must use$, escape it as$$in the YAML file.
Create the custom MariaDB configuration directory and file:
mkdir -p ~/docker/mariadb
nano ~/docker/mariadb/my.cnfCode language: JavaScript (javascript)
[mysqld]
<em># Character set</em>
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
<em># InnoDB tuning</em>
innodb_buffer_pool_size = 1G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
<em># Query tuning</em>
max_allowed_packet = 64M
tmp_table_size = 64M
max_heap_table_size = 64M
query_cache_type = 0
query_cache_size = 0
<em># Connections</em>
max_connections = 150Code language: HTML, XML (xml)
A few notes on these settings:
innodb_buffer_pool_size = 1G — this is the single most impactful MariaDB setting. It should be roughly 50-70% of the memory available to the MariaDB container. With our 2 GB mem_limit, 1 GB is appropriate.innodb_flush_log_at_trx_commit = 2 — a trade-off between performance and durability. The value 2 flushes to the OS buffer on each commit but only writes to disk once per second. Slightly less durable than 1, but noticeably faster. Acceptable for most stores given we have other backup mechanisms.query_cache_type = 0 — the query cache is disabled on purpose. Magento’s workload (lots of writes during indexing, varied queries) makes the query cache counterproductive. It causes more lock contention than it saves.OpenSearch requires a kernel parameter adjustment. Without this, the container will crash on startup.
sudo sysctl -w vm.max_map_count=262144
Make it persistent across reboots:
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.confCode language: PHP (php)
Why OpenSearch 2.19.1? Magento 2.4.8 officially supports OpenSearch 2.x. We’re using version 2.19.1 as it’s the latest stable in the 2.x line at the time of writing. Check the Magento system requirements if you’re reading this later — the supported version may have changed.
Why disable the security plugin? Since OpenSearch is bound to 127.0.0.1 and only accessible from the server itself, the security plugin adds complexity without meaningful benefit. In a multi-server setup you’d want it enabled.
JVM heap sizing (-Xms768m -Xmx768m) — both the minimum and maximum heap are set to the same value. This prevents the JVM from wasting time resizing the heap. 768 MB gives comfortable headroom for small to mid-size catalogs. If you have tens of thousands of products, increase this (and the mem_limit accordingly).
We’re running two separate Redis instances with different jobs:
Redis Cache (port 6379):
--maxmemory 512mb — caps memory usage.--maxmemory-policy allkeys-lru — when memory is full, evict the least recently used keys. This is the correct policy for a cache — stale data should be evicted to make room for fresh data.--save "" — disables disk persistence. Cache data is disposable — if Redis restarts, Magento will simply rebuild the cache. There’s no point saving it to disk.Redis Sessions (port 6380):
--maxmemory 256mb — sessions use less memory than cache.--maxmemory-policy allkeys-lru — same eviction policy, but for sessions this means the oldest inactive sessions get dropped first if memory is full.--appendonly yes — enables the append-only file for persistence. Unlike cache, sessions matter — you don’t want all your logged-in customers to lose their carts because Redis restarted.Note that Redis Sessions is mapped to port 6380 on the host while internally it still runs on the default Redis port 6379. This lets both instances coexist on the same server.
RabbitMQ is straightforward. We’re using the management image variant, which includes a web UI accessible at http://127.0.0.1:15672. This is useful for debugging queue issues — you can see message counts, consumer status, and connection health.
The management UI is bound to 127.0.0.1, so it’s not accessible from the internet. To access it, use an SSH tunnel:
ssh -N -L 15672:127.0.0.1:15672 magento@YOUR_SERVER_IPCode language: CSS (css)
This opens a tunnel without starting a shell session — press Ctrl+C to close it when you’re done. Then open http://localhost:15672 in your browser and log in with the credentials from your docker-compose.yml.
Before starting the containers, we need a minimal Varnish VCL file. We’ll replace this with the proper Magento-generated VCL later in Step 9, but Varnish won’t start without one.
mkdir -p ~/docker/varnish
nano ~/docker/varnish/default.vclCode language: JavaScript (javascript)
vcl 4.1;
backend default {
.host = "host.docker.internal";
.port = "8080";
.first_byte_timeout = 600s;
}
sub vcl_recv {
return (pass);
}Code language: JavaScript (javascript)
This placeholder VCL simply passes all requests through to the Nginx backend on port 8080 without any caching. It exists solely so the Varnish container can start. We’ll generate the real Magento VCL in Step 9.
Note on
host.docker.internal: This is how a Docker container refers to the host machine. Since Nginx and PHP-FPM run on the host (not in Docker), Varnish needs this address to reach them. Docker Desktop supports this natively. On Linux, we need to add it explicitly — we’ll handle that next.
Now start everything:
cd ~/docker
docker compose up -d
Watch the startup:
docker compose logs -f
Give it a minute or two. OpenSearch in particular takes a while to initialise on first run. Press Ctrl+C to stop following logs once you see everything stabilise.
Check that every container is running:
docker compose -f ~/docker/docker-compose.yml psCode language: JavaScript (javascript)
All six containers should show Up status. Now verify each service individually:
<em># MariaDB — can we connect?</em>
docker exec magento-mariadb mariadb -u magento -p'your_magento_db_password_here' -e "SELECT 1;"
<em># Expected: a simple result set with "1"</em>
<em># OpenSearch — is the cluster healthy?</em>
curl -s http://127.0.0.1:9200/_cluster/health?pretty
<em># Expected: "status" : "green" (or "yellow" for a single node, which is fine)</em>
<em># Redis Cache — is it responding?</em>
docker exec magento-redis-cache redis-cli PING
<em># Expected: PONG</em>
<em># Redis Sessions — is it responding?</em>
docker exec magento-redis-session redis-cli PING
<em># Expected: PONG</em>
<em># RabbitMQ — is it responding?</em>
curl -s -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/healthchecks/node
<em># Expected: {"status":"ok"}</em>
<em># Varnish — is it responding? (will return a 503 since Nginx isn't up yet, but that's expected)</em>
curl -s -I http://127.0.0.1:6081
<em># Expected: HTTP/1.1 503 Backend fetch failed (this is fine — the backend doesn't exist yet)</em>Code language: PHP (php)
Also verify that none of these ports are accessible from outside the server. From your local machine (not the server), try:
curl -s --connect-timeout 3 http://YOUR_SERVER_IP:3306
curl -s --connect-timeout 3 http://YOUR_SERVER_IP:9200
curl -s --connect-timeout 3 http://YOUR_SERVER_IP:6379Code language: JavaScript (javascript)
All three should time out or be refused. If any of them connect, something is wrong with your port binding — go back and make sure every port in docker-compose.yml starts with 127.0.0.1:.
If all checks pass, your Docker infrastructure is running and properly secured. On to Step 3 — installing Nginx and PHP on the host.
This is where we set up the two host-level services that handle Magento’s web traffic and PHP execution. Nginx serves two roles in our architecture — it terminates SSL on port 443 and proxies to Varnish, and it handles PHP routing on port 8080 behind Varnish. PHP-FPM runs alongside it to execute Magento’s PHP code.
sudo apt update
sudo apt install -y nginx
Verify it’s running:
sudo systemctl status nginx
For now, we just need Nginx installed. We’ll configure the server blocks in later steps (Step 10 for the SSL proxy, and right now we’ll set up the port 8080 backend block that PHP-FPM uses).
This is the server block that Varnish forwards requests to. It handles all the PHP routing for Magento. Create the configuration file:
sudo nano /etc/nginx/sites-available/magento-backend
upstream fastcgi_backend {
server unix:/run/php/php8.3-fpm.sock;
}
server {
listen 8080;
server_name _;
set $MAGE_ROOT /var/www/magento;
set $MAGE_MODE production;
<em># Load the Magento nginx config</em>
include /var/www/magento/nginx.conf.sample;
<em># Increase timeouts for slow Magento operations</em>
fastcgi_read_timeout 600s;
proxy_read_timeout 600s;
<em># Logging</em>
access_log /var/log/nginx/magento-backend-access.log;
error_log /var/log/nginx/magento-backend-error.log;
}Code language: PHP (php)
A few things to note:
host.docker.internal:8080.$MAGE_ROOT — points to where we’ll install Magento in Step 4.nginx.conf.sample — Magento ships with a comprehensive Nginx configuration file. Rather than writing our own rewrite rules, we include theirs. It handles URL rewrites, static file serving, PHP routing, and security rules. This file won’t exist yet (we haven’t installed Magento), so Nginx will fail to reload until Step 4 is done — that’s expected.fastcgi_read_timeout 600s — Magento operations like reindexing or compilation triggered via the admin can take a long time. The default 60s timeout will kill these requests.Enable the site and disable the default:
sudo ln -s /etc/nginx/sites-available/magento-backend /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/defaultCode language: JavaScript (javascript)
Don’t reload Nginx yet — it will fail because /var/www/magento/nginx.conf.sample doesn’t exist. We’ll do that after installing Magento in Step 4.
Magento 2.4.8 requires PHP 8.3. We’ll install it from the OndÅ™ej Surý PPA, which is the standard source for up-to-date PHP packages on Ubuntu:
sudo apt install -y software-properties-common
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
Install PHP-FPM and all the extensions Magento requires:
sudo apt install -y \
php8.3-fpm \
php8.3-cli \
php8.3-common \
php8.3-mysql \
php8.3-xml \
php8.3-xmlrpc \
php8.3-curl \
php8.3-gd \
php8.3-imagick \
php8.3-intl \
php8.3-mbstring \
php8.3-opcache \
php8.3-soap \
php8.3-zip \
php8.3-bcmathCode language: CSS (css)
Note: The
sodiumextension is built into PHP 8.3 core — no separate package needed.
Verify the installation:
php -v
<em># Expected: PHP 8.3.x</em>
php -m | head -30
<em># Should list the installed modules</em>Code language: HTML, XML (xml)
Since we’re configuring Magento with secure (HTTPS) base URLs, but the backend Nginx receives plain HTTP requests from Varnish, we need to tell PHP that the original request was HTTPS. Without this, Magento will respond with a 302 redirect to HTTPS on every request, breaking Varnish caching entirely.
Edit the global fastcgi_params file:
sudo nano /etc/nginx/fastcgi_params
Find this line:
fastcgi_param HTTPS $https if_not_empty;Code language: PHP (php)
Replace it with:
fastcgi_param HTTPS on;
This tells PHP that every request is HTTPS. Since this server is dedicated to Magento and all traffic arrives through Cloudflare (which handles SSL), this is safe and correct.
The default PHP-FPM pool works, but the defaults aren’t tuned for Magento. Edit the pool configuration:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Find and adjust these settings:
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
<em>; Process manager settings</em>
pm = dynamic
pm.max_children = 20
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 1000Code language: HTML, XML (xml)
What these settings mean:
pm = dynamic — PHP-FPM adjusts the number of worker processes based on load. This is the right choice for most setups. The alternative static keeps all workers running at all times, which uses more memory but avoids the overhead of spawning new workers.pm.max_children = 20 — the maximum number of simultaneous PHP workers. Each Magento request ties up one worker. On a 16 GB server with our Docker services running, 20 workers is a safe ceiling — each worker can consume 128–256 MB under Magento. If you find yourself hitting this limit under traffic, consider upgrading to a larger server rather than just increasing this number.pm.max_requests = 1000 — after handling 1000 requests, a worker process is recycled. This prevents memory leaks from accumulating over time. Magento is notorious for gradual memory creep, and this setting keeps it in check.Edit the FPM php.ini:
sudo nano /etc/php/8.3/fpm/php.ini
Find and adjust these settings:
memory_limit = 768M
max_execution_time = 600
max_input_time = 600
max_input_vars = 10000
post_max_size = 64M
upload_max_filesize = 64M
realpath_cache_size = 10M
realpath_cache_ttl = 7200
memory_limit = 768M — Magento’s recommended minimum is 756 MB. We’re rounding up slightly. Some operations (large imports, complex category trees) can push this, but going higher means each PHP-FPM worker consumes more memory, which limits how many workers you can run.max_input_vars = 10000 — Magento’s admin panels, especially for configurable products and system configuration, submit forms with thousands of variables. The default of 1000 will cause silent data loss.realpath_cache_size and realpath_cache_ttl — Magento has a very deep directory structure with thousands of files. Increasing the realpath cache reduces the number of filesystem stat calls PHP needs to make.Also edit the CLI php.ini (used when running php bin/magento commands):
sudo nano /etc/php/8.3/cli/php.ini
Set:
memory_limit = 2G
max_execution_time = 0
The CLI needs more memory than FPM because operations like setup:di:compile and setup:static-content:deploy are memory-intensive. Setting max_execution_time = 0 means no timeout — CLI commands should run until they finish.
OPcache is the single biggest performance lever for PHP. It compiles PHP scripts to bytecode and caches the result in shared memory, so PHP doesn’t have to re-parse and re-compile every file on every request. Without it, Magento is painfully slow.
Edit the OPcache configuration:
sudo nano /etc/php/8.3/fpm/conf.d/10-opcache.ini
You’ll see an existing file with a few defaults. Keep what’s there (including opcache.jit=off) and add the following settings:
opcache.enable=1
opcache.memory_consumption=512
opcache.max_accelerated_files=130986
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.enable_cli=0
Your complete file should look like:
<em>; configuration for php opcache module</em>
<em>; priority=10</em>
zend_extension=opcache.so
opcache.jit=off
opcache.enable=1
opcache.memory_consumption=512
opcache.max_accelerated_files=130986
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.enable_cli=0Code language: HTML, XML (xml)
Why keep
opcache.jit=off? JIT (Just-In-Time compilation) compiles PHP bytecode to native machine code. It sounds like a free performance win, but Magento is I/O-bound (database queries, Redis calls, filesystem reads), not CPU-bound. JIT provides negligible benefit for this workload while increasing memory usage. Keep it off.
The critical settings explained:
opcache.memory_consumption=512 — 512 MB for the bytecode cache. Magento has thousands of PHP files and a fully compiled store can easily use 200–300 MB. Giving it 512 MB provides headroom for third-party extensions.opcache.max_accelerated_files=130986 — the maximum number of files OPcache can store. The default (10000) is too low for Magento. This value must be a prime number — 130986 is the next prime above 130000.opcache.save_comments=1 — Magento’s dependency injection system reads annotations from PHP comments. Disabling this breaks Magento.opcache.validate_timestamps=0 — this is important and has a trade-off. When set to 0, OPcache never checks whether PHP files have changed on disk. It serves the cached bytecode forever until you explicitly tell it to refresh. This is a significant performance gain — it eliminates stat() calls on every request — but it means deploying new code or installing extensions won’t take effect until you reload PHP-FPM.With validate_timestamps=0, you need to reload PHP-FPM after any code change for the new code to take effect. This includes Magento upgrades, extension installations, running setup:di:compile, and deploying frontend theme changes.
The most reliable approach:
sudo systemctl reload php8.3-fpmCode language: CSS (css)
This gracefully reloads PHP-FPM — existing requests finish with the old code, new requests get the fresh bytecode cache. It’s not a restart, so there’s no downtime.
Common gotcha: You’ve deployed new frontend changes but the site still shows the old version. The first thing to check is whether you reloaded PHP-FPM. With
validate_timestamps=0, this is almost always the cause. This catches people especially with CSS/JS changes that are processed through Magento’s static content pipeline — even though they’re “frontend” files, they’re compiled by PHP.
We’ll cover a proper deployment script later in the guide that includes this reload step automatically.
Apply all the configuration changes:
sudo systemctl restart php8.3-fpm
sudo systemctl status php8.3-fpmCode language: CSS (css)
Ensure it’s running with no errors.
<em># PHP version</em>
php -v
<em># Expected: PHP 8.3.x (cli)</em>
<em># PHP-FPM is running</em>
sudo systemctl status php8.3-fpm
<em># Expected: active (running)</em>
<em># FPM socket exists</em>
ls -la /run/php/php8.3-fpm.sock
<em># Expected: socket file owned by www-data</em>
<em># OPcache is loaded</em>
php -i | grep opcache.enable
<em># Expected: opcache.enable => On => On</em>
<em># Required extensions are present</em>
php -m | grep -E "mysql|xml|curl|gd|intl|mbstring|opcache|soap|zip|bcmath"
<em># Expected: all extensions listed</em>
<em># Nginx is installed (but won't reload successfully until Magento is installed)</em>
nginx -v
<em># Expected: nginx version: nginx/x.x.x</em>
<em># Confirm Nginx backend config has no syntax errors (will warn about missing Magento include — that's expected)</em>
sudo nginx -t
<em># Expected: may show a warning about /var/www/magento/nginx.conf.sample — this is fine, we'll fix it in Step 4</em>Code language: HTML, XML (xml)
Note:
sudo nginx -twill likely fail at this point because/var/www/magento/nginx.conf.sampledoesn’t exist yet. That’s expected — once we install Magento in Step 4, we’ll reload Nginx and everything will connect.
If PHP-FPM is running with the correct version, the socket exists, and OPcache is loaded, you’re ready for Step 4 — installing Magento itself.
Now we install Magento itself. We’ll use Composer to pull the project, configure file permissions, run the installer, and switch to production mode.
cd ~
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composerCode language: JavaScript (javascript)
Verify:
composer --version
<em># Expected: Composer version 2.x.x</em>Code language: HTML, XML (xml)
Composer needs your Magento Marketplace keys to download Magento packages. Your public key is the username and your private key is the password.
composer global config http-basic.repo.magento.com YOUR_PUBLIC_KEY YOUR_PRIVATE_KEYCode language: CSS (css)
This stores the credentials in ~/.config/composer/auth.json so you won’t be prompted every time you run Composer.
Create the web root directory and install Magento:
sudo mkdir -p /var/www/magento
sudo chown magento:www-data /var/www/magentoCode language: JavaScript (javascript)
Create a symlink in your home directory for quick navigation:
ln -s /var/www/magento ~/wwwCode language: JavaScript (javascript)
Now you can jump to the Magento root from anywhere with cd ~/www.
Install Magento via Composer:
cd /var/www
composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition=2.4.8 magentoCode language: JavaScript (javascript)
This will take a few minutes — Composer is downloading Magento and all its dependencies. You’ll see a lot of output as packages are pulled in.
Note: We’re installing version
2.4.8here. If a patch version is available (e.g.,2.4.8-p4), you can install that directly instead:magento/project-community-edition=2.4.8-p4. We’ll also cover updating to the latest patch version in Step 5.
Magento’s file permissions are critical — get them wrong and you’ll either have security issues or Magento won’t be able to write to directories it needs. The principle is: the magento user owns the files (for CLI operations and deployments), and www-data (the PHP-FPM user) needs read access to everything and write access to specific directories.
cd /var/www/magento
<em># Set ownership: magento user, www-data group</em>
sudo chown -R magento:www-data .
<em># Files readable by owner and group, not world-readable</em>
sudo find . -type f -exec chmod 640 {} \;
<em># Directories readable and traversable by owner and group</em>
sudo find . -type d -exec chmod 750 {} \;
<em># Directories that Magento needs to write to</em>
sudo chmod -R 770 var/ generated/ pub/static/ pub/media/ app/etc/
<em># Make log directory group-readable so the magento user can view logs</em>
sudo chmod -R g+r var/log/
<em># Make sure the bin/magento CLI is executable</em>
sudo chmod 750 bin/magentoCode language: PHP (php)
Now we run setup:install to configure Magento and create the database schema. This is where all the services from Step 2 come together.
Replace the placeholder values with your actual passwords and desired admin credentials:
cd /var/www/magento
php bin/magento setup:install \
--base-url=https://yourdomain.com/ \
--base-url-secure=https://yourdomain.com/ \
--use-secure=1 \
--db-host=127.0.0.1 \
--db-name=magento \
--db-user=magento \
--db-password='your_magento_db_password_here' \
--admin-firstname=Admin \
--admin-lastname=User \
--admin-email=admin@yourdomain.com \
--admin-user=admin \
--admin-password='YourStrongAdminPassword1!' \
--backend-frontname=admin_RANDOMSTRING \
--language=en_US \
--currency=USD \
--timezone=UTC \
--use-rewrites=1 \
--search-engine=opensearch \
--opensearch-host=127.0.0.1 \
--opensearch-port=9200 \
--opensearch-index-prefix=magento2 \
--cache-backend=redis \
--cache-backend-redis-server=127.0.0.1 \
--cache-backend-redis-port=6379 \
--cache-backend-redis-db=0 \
--page-cache=redis \
--page-cache-redis-server=127.0.0.1 \
--page-cache-redis-port=6379 \
--page-cache-redis-db=1 \
--session-save=redis \
--session-save-redis-host=127.0.0.1 \
--session-save-redis-port=6380 \
--session-save-redis-db=0 \
--amqp-host=127.0.0.1 \
--amqp-port=5672 \
--amqp-user=magento \
--amqp-password='your_rabbitmq_password_here' \
--amqp-virtualhost=/ \
--cleanup-databaseCode language: JavaScript (javascript)
Let’s break down the important parts:
Base URLs — set these to your actual domain with https://. Even though we haven’t set up SSL yet, Magento needs to know the final URLs from the start. Cloudflare will handle SSL termination in Step 11.
--backend-frontname=admin_RANDOMSTRING — this is the URL path for the admin panel (e.g., yourdomain.com/admin_RANDOMSTRING). Don’t use admin or backend — these are the first paths attackers try. Use something random and hard to guess. You can generate a random string with openssl rand -hex 6.
Database — connects to MariaDB via 127.0.0.1:3306 using the credentials from our Docker setup.
OpenSearch — connects to 127.0.0.1:9200. The --opensearch-index-prefix=magento2 means all Magento indices in OpenSearch will be prefixed with magento2_, which is useful if you ever share the OpenSearch instance between environments.
Redis Cache — we’re using the cache Redis instance (port 6379) for both default cache (db=0) and full page cache (db=1). Using separate database numbers within the same Redis instance keeps them logically separated while sharing the same maxmemory pool.
Redis Sessions — uses the separate Redis instance on port 6380. This is the instance with persistence (appendonly yes) so sessions survive restarts.
RabbitMQ — connects to the RabbitMQ instance for async message processing.
--cleanup-database — drops and recreates the Magento database tables. Safe to use on a fresh install.
The installer will take a few minutes. When it completes, you’ll see output ending with something like:
[SUCCESS]: Magento installation complete.
[SUCCESS]: Magento Admin URI: /admin_RANDOMSTRING
Magento 2.4.8 requires Two-Factor Authentication (2FA) for admin login by default. The first login will try to send an email with setup instructions — but since we haven’t configured email yet, this will fail and lock you out of the admin.
Disable 2FA for now so you can access the admin panel:
cd /var/www/magento
php bin/magento module:disable Magento_AdminAdobeImsTwoFactorAuth Magento_TwoFactorAuth
php bin/magento setup:upgrade
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f
php bin/magento cache:flush
sudo systemctl reload php8.3-fpm
sudo chown -R magento:www-data var/ generated/ pub/static/ pub/media/ app/etc/
sudo chmod -R 770 var/ generated/ pub/static/ pub/media/ app/etc/Code language: JavaScript (javascript)
Important: Re-enable 2FA before going live with real traffic. Running a production store without 2FA on the admin panel is a security risk. Once you have SMTP email configured, re-enable it with:
php bin/magento module:enable Magento_AdminAdobeImsTwoFactorAuth Magento_TwoFactorAuthfollowed byphp bin/magento setup:upgrade. This is included in the Final Checklist at the end of the guide.
Now that Magento is installed, the nginx.conf.sample file exists. Reload Nginx:
sudo nginx -t
<em># Expected: syntax is ok, test is successful</em>
sudo systemctl reload nginxCode language: HTML, XML (xml)
This should now succeed without errors. If nginx -t still fails, check the error message — it will tell you exactly which line has an issue.
Magento has three modes: default, developer, and production. A fresh install starts in default mode. Production mode is significantly faster because it pre-compiles everything and disables real-time code generation.
cd /var/www/magento
php bin/magento deploy:mode:set productionCode language: JavaScript (javascript)
This command does several things automatically:
setup:di:compile — generates all dependency injection classessetup:static-content:deploy — compiles and deploys all CSS, JS, and HTML assetsIt will take a few minutes. When it finishes, reload PHP-FPM to clear OPcache with the freshly compiled code:
sudo systemctl reload php8.3-fpmCode language: CSS (css)
The deploy:mode:set production command generates files as your current user (magento). Make sure the www-data group still has proper access:
cd /var/www/magento
sudo chown -R magento:www-data var/ generated/ pub/static/ pub/media/ app/etc/
sudo chmod -R 770 var/ generated/ pub/static/ pub/media/ app/etc/Code language: JavaScript (javascript)
<em># Magento is installed and in production mode</em>
cd /var/www/magento
php bin/magento deploy:mode:show
<em># Expected: Current application mode: production.</em>
<em># Magento can talk to the database</em>
php bin/magento setup:db:status
<em># Expected: All modules are up to date.</em>
<em># Magento version</em>
php bin/magento --version
<em># Expected: Magento CLI 2.4.8</em>
<em># Nginx config is valid and reloaded</em>
sudo nginx -t
<em># Expected: syntax is ok, test is successful</em>
<em># The backend responds (through Nginx on 8080, bypassing Varnish for now)</em>
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/
<em># Expected: 200 or 302 (redirect to HTTPS is normal)</em>
<em># OpenSearch indices were created</em>
curl -s http://127.0.0.1:9200/_cat/indices?v | grep magento2
<em># Expected: one or more indices starting with magento2_</em>
<em># Redis has data</em>
docker exec magento-redis-cache redis-cli INFO keyspace
<em># Expected: db0 should show keys. db1 (full page cache) may not appear yet if no frontend pages have been visited — that's normal.</em>
<em># Check Magento's env.php has all services configured</em>
grep -c "redis" /var/www/magento/app/etc/env.php
<em># Expected: a number greater than 0 (multiple Redis references)</em>
grep "amqp" /var/www/magento/app/etc/env.php
<em># Expected: lines showing RabbitMQ configuration</em>Code language: HTML, XML (xml)
At this point, Magento is installed, in production mode, and connected to all backend services. You should be able to access the backend on port 8080 directly (though in production, traffic will flow through Varnish and the SSL Nginx proxy).
Important: Keep your
app/etc/env.phpfile safe — it contains all your database credentials, Redis configuration, and service passwords. We’ll include it in our backup strategy later.
If all checks pass, you’re ready for Step 5 — applying security patches.
Adobe releases security patches for Magento regularly — typically as point releases like 2.4.8-p1, 2.4.8-p2, etc. These patches fix vulnerabilities and should be applied as soon as they’re available. Running an unpatched Magento store in production is an open invitation for trouble.
If you installed the latest patch version directly in Step 4 (e.g., magento/project-community-edition=2.4.8-p1), you may already be on the latest release. If you installed the base 2.4.8, this step brings you up to date.
cd /var/www/magento
php bin/magento --version
<em># Shows your current Magento version</em>
composer show magento/product-community-edition
<em># Shows the installed Composer package version</em>Code language: PHP (php)
Check what’s available on the Magento release page or simply run:
cd /var/www/magento
composer require-commerce magento/product-community-edition=2.4.8-p4 --no-update
composer updateCode language: JavaScript (javascript)
Replace 2.4.8-p4 with whatever the latest patch version is at the time you’re reading this.
Note: Magento ships with a custom Composer plugin that requires you to use
require-commerceinstead of the standardcomposer requirefor the metapackage. If you accidentally runcomposer require, you’ll get an error — just userequire-commerceinstead.
Note: If there’s no newer patch version than what you installed in Step 4, you can skip the
composer requireandcomposer updatecommands. Runphp bin/magento --versionto confirm you’re on the latest, and move on to Step 6.
After the update completes:
php bin/magento setup:upgrade
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f
php bin/magento cache:flushCode language: JavaScript (javascript)
Reload PHP-FPM to clear OPcache:
sudo systemctl reload php8.3-fpmCode language: CSS (css)
Fix ownership after compilation:
sudo chown -R magento:www-data var/ generated/ pub/static/ pub/media/ app/etc/
sudo chmod -R 770 var/ generated/ pub/static/ pub/media/ app/etc/Code language: JavaScript (javascript)
<em># Confirm the updated version</em>
cd /var/www/magento
php bin/magento --version
<em># Expected: Magento CLI 2.4.8-p4 (or whatever patch you applied)</em>
<em># Database is up to date</em>
php bin/magento setup:db:status
<em># Expected: All modules are up to date.</em>
<em># Production mode is still active</em>
php bin/magento deploy:mode:show
<em># Expected: Current application mode: production.</em>
<em># Backend still responds</em>
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/
<em># Expected: 200 or 302</em>Code language: HTML, XML (xml)
If you were already on the latest patch (or no patches exist yet for 2.4.8), these checks just confirm nothing was disrupted. Either way, you’re now ready for Step 6 — configuring Redis.
If you followed Step 4 and included the Redis flags in the setup:install command, Redis is already configured. This step is about verifying that everything is wired up correctly and understanding what Magento is actually doing with Redis.
Magento uses Redis for three distinct purposes, and we’ve set up each one on a specific Redis database or instance:
Default Cache (Redis Cache instance, port 6379, db 0) — stores configuration cache, layout XML cache, block HTML cache, collections data, reflection data, and various other caches that Magento generates. When you run php bin/magento cache:flush, this is what gets cleared.
Full Page Cache (Redis Cache instance, port 6379, db 1) — stores the complete rendered HTML of pages. This works alongside Varnish — Magento stores page cache metadata here while Varnish handles the actual HTTP-level caching. When a page is purged from Varnish, Magento also invalidates it here.
Sessions (Redis Sessions instance, port 6380, db 0) — stores customer session data (login state, cart contents, recently viewed products, form keys). This is on a separate Redis instance with persistence enabled because losing sessions means logging out every customer and emptying their carts.
Check that Magento’s env.php has the correct Redis settings:
cd /var/www/magento
php -r "
\$config = include 'app/etc/env.php';
echo '=== Default Cache ===' . PHP_EOL;
print_r(\$config['cache']['frontend']['default']);
echo PHP_EOL . '=== Page Cache ===' . PHP_EOL;
print_r(\$config['cache']['frontend']['page_cache']);
echo PHP_EOL . '=== Sessions ===' . PHP_EOL;
print_r(\$config['session']);
"Code language: PHP (php)
You should see output confirming:
server: 127.0.0.1, port: 6379, database: 0server: 127.0.0.1, port: 6379, database: 1host: 127.0.0.1, port: 6380, database: 0Check that keys exist in Redis:
<em># Default cache — should have keys after installation</em>
docker exec magento-redis-cache redis-cli -n 0 DBSIZE
<em># Expected: a number greater than 0</em>
<em># Full page cache — will populate once frontend pages are requested</em>
docker exec magento-redis-cache redis-cli -n 1 DBSIZE
<em># Expected: 0 is fine if you haven't requested the frontend yet</em>
<em># Sessions — will populate once a storefront request is made</em>
docker exec magento-redis-session redis-cli -n 0 DBSIZE
<em># Expected: 0 is fine if no requests have been made yet</em>Code language: PHP (php)
Note: We haven’t set up the SSL proxy or Cloudflare yet, so you can’t visit your store in a browser. To trigger frontend requests at this stage, use curl from the server itself:
curl -s http://127.0.0.1:8080/— this goes directly to Nginx on port 8080, bypassing Varnish and SSL.
Flush the cache and confirm it rebuilds:
cd /var/www/magento
<em># Check current key count</em>
docker exec magento-redis-cache redis-cli -n 0 DBSIZE
<em># Flush Magento cache</em>
php bin/magento cache:flush
<em># Check again — should be 0 or very low</em>
docker exec magento-redis-cache redis-cli -n 0 DBSIZE
<em># Trigger a request to rebuild cache</em>
curl -s -o /dev/null http://127.0.0.1:8080/
<em># Check again — should have keys</em>
docker exec magento-redis-cache redis-cli -n 0 DBSIZECode language: PHP (php)
If the key count drops after flush and rebuilds after a request, Redis default caching is working correctly.
Note: The full page cache (db 1) may still show 0 keys at this stage. Magento’s full page cache is designed for real browser visits with proper HTTP headers and cookies — a bare
curlrequest doesn’t trigger it the same way. This is normal. You’ll see full page cache populate once you access the site through a browser via Cloudflare in Step 11. The sessions Redis instance will show activity from curl requests though, since Magento creates a session for every request.
<em># Redis Cache instance is responding</em>
docker exec magento-redis-cache redis-cli PING
<em># Expected: PONG</em>
<em># Redis Sessions instance is responding</em>
docker exec magento-redis-session redis-cli PING
<em># Expected: PONG</em>
<em># Default cache has keys</em>
docker exec magento-redis-cache redis-cli -n 0 DBSIZE
<em># Expected: number greater than 0</em>
<em># Sessions Redis has persistence enabled</em>
docker exec magento-redis-session redis-cli CONFIG GET appendonly
<em># Expected: appendonly -> yes</em>
<em># Cache Redis has correct eviction policy</em>
docker exec magento-redis-cache redis-cli CONFIG GET maxmemory-policy
<em># Expected: maxmemory-policy -> allkeys-lru</em>
<em># env.php has Redis configuration</em>
grep -A 2 "'cache'" /var/www/magento/app/etc/env.php | head -5
<em># Expected: lines showing cache configuration</em>Code language: HTML, XML (xml)
Redis is verified and working. On to Step 7 — configuring OpenSearch.
Like Redis, OpenSearch was already connected to Magento during the setup:install command in Step 4. This step verifies the connection, checks cluster health, and ensures Magento’s search indices are built correctly.
OpenSearch powers everything search-related in your store:
Magento maintains several indices in OpenSearch that mirror data from the database. When you add or modify products, Magento updates these indices either in real-time or via the indexer cron job (depending on your indexer mode).
curl -s http://127.0.0.1:9200/_cluster/health?prettyCode language: JavaScript (javascript)
You should see output like:
{
"cluster_name" : "docker-cluster",
"status" : "green",
"number_of_nodes" : 1,
"number_of_data_nodes" : 1,
...
}Code language: JavaScript (javascript)
The status field is what matters:
curl -s http://127.0.0.1:9200/_cat/indices?v | grep magento2Code language: JavaScript (javascript)
You should see at least one index with the magento2_ prefix:
yellow open magento2_product_1_v2 ...
On a fresh install with no products, you may only see the product index. Additional indices (like catalogsearch_fulltext) are created as you add products and run reindexing. One index at this stage is perfectly normal.
cd /var/www/magento
php bin/magento config:show catalog/search/engine
<em># Expected: opensearch</em>Code language: HTML, XML (xml)
Magento uses indexers to keep OpenSearch in sync with the database. Check their status:
cd /var/www/magento
php bin/magento indexer:statusCode language: JavaScript (javascript)
You should see a list of indexers, all showing Ready status. If any show Reindex required, run:
php bin/magento indexer:reindex
This can take anywhere from a few seconds (empty store) to several minutes (large catalog). On a fresh install it should be quick.
Each indexer can run in one of two modes:
For production, “Update by Schedule” is recommended:
php bin/magento indexer:set-mode scheduleCode language: JavaScript (javascript)
This requires cron to be running, which we’ll set up in Step 12. Until then, you can manually reindex with php bin/magento indexer:reindex.
<em># OpenSearch cluster health</em>
curl -s http://127.0.0.1:9200/_cluster/health | python3 -m json.tool | grep status
<em># Expected: "status": "green" or "status": "yellow"</em>
<em># Magento indices exist</em>
curl -s http://127.0.0.1:9200/_cat/indices?v | grep magento2
<em># Expected: at least one index listed (fresh install may only show the product index)</em>
<em># Search engine is set to OpenSearch</em>
cd /var/www/magento
php bin/magento config:show catalog/search/engine
<em># Expected: opensearch</em>
<em># All indexers are ready</em>
php bin/magento indexer:status
<em># Expected: all indexers show "Ready"</em>
<em># OpenSearch responds to a search query (returns empty results on a fresh store, but no errors)</em>
curl -s "http://127.0.0.1:9200/magento2_product_*/_search?q=*&size=1" | python3 -m json.tool | head -15
<em># Expected: valid JSON response with "hits" object and "total" showing a value (0 on a fresh store is fine)</em>Code language: PHP (php)
OpenSearch is configured and healthy. On to Step 8 — configuring RabbitMQ.
RabbitMQ was connected to Magento during setup:install, so the connection is already in place. This step explains what RabbitMQ does for Magento, how to verify it’s working, and how to set up the queue consumers that process messages.
RabbitMQ is a message broker — it sits between the parts of Magento that create work and the parts that do the work. Instead of processing everything synchronously during a web request (which would make the request slow), Magento publishes a message to a queue, and a background consumer process picks it up later.
Examples of what Magento queues:
Without RabbitMQ, Magento falls back to database-based queuing (using the queue table in MySQL), which works but is slower and doesn’t scale as well.
Check that Magento’s env.php has the AMQP configuration:
cd /var/www/magento
php -r "
\$config = include 'app/etc/env.php';
echo '=== AMQP Configuration ===' . PHP_EOL;
print_r(\$config['queue']['amqp']);
"Code language: PHP (php)
You should see your RabbitMQ host, port, user, and virtual host listed.
Check that RabbitMQ itself is healthy:
curl -s -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/healthchecks/node
<em># Expected: {"status":"ok"}</em>Code language: JavaScript (javascript)
List the queues that Magento has created:
curl -s -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/queues | python3 -m json.tool | grep '"name"'Code language: JavaScript (javascript)
On a fresh install, you may see no queues yet — they’re created when consumers start running or when Magento first publishes a message. That’s normal.
Magento’s queue consumers are background processes that read messages from RabbitMQ and process them. You can see which consumers are available:
cd /var/www/magento
php bin/magento queue:consumers:listCode language: PHP (php)
This will show a list of consumer names. On a fresh install you’ll see consumers like product_action_attribute.update, exportProcessor, codegeneratorProcessor, and others.
For now, we won’t start consumers manually — they’ll be managed by Magento’s cron system, which we’ll configure in Step 12. Magento’s cron automatically starts consumers for the consumers cron group.
<em># RabbitMQ is healthy</em>
curl -s -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/healthchecks/node
<em># Expected: {"status":"ok"}</em>
<em># RabbitMQ connection details are in env.php</em>
cd /var/www/magento
grep -A 1 "'amqp'" app/etc/env.php
<em># Expected: 'amqp' => [</em>
<em># List available consumers</em>
php bin/magento queue:consumers:list
<em># Expected: a list of consumer names</em>
<em># RabbitMQ management UI is reachable (via SSH tunnel if you want to check in a browser)</em>
curl -s -o /dev/null -w "%{http_code}" -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/overview
<em># Expected: 200</em>Code language: PHP (php)
Tip: If you want to visually inspect RabbitMQ, set up an SSH tunnel from your local machine:
ssh -N -L 15672:127.0.0.1:15672 magento@YOUR_SERVER_IP, then openhttp://localhost:15672in your browser. PressCtrl+Cto close the tunnel when you’re done. Log in with your RabbitMQ credentials to see connections, channels, queues, and message rates. It’s a useful debugging tool when things go wrong.
RabbitMQ is connected and ready. The queue consumers will be handled automatically once we set up cron in Step 12. On to Step 9 — setting up Varnish for full page cache.
This is the step that makes your store fast. Varnish sits between the SSL-terminating Nginx (port 443) and the backend Nginx (port 8080), caching full HTML pages in memory and serving them without ever touching PHP. For anonymous visitors — which is the majority of your traffic — a properly configured Varnish can serve pages in single-digit milliseconds.
In Step 2 we set up Varnish with a placeholder VCL that passes everything through. Now we’ll replace it with Magento’s generated VCL, configure cache purging, and enable Varnish as Magento’s full page cache handler.
Important: Throughout this step, replace
yourdomain.comwith your actual domain in all commands. Varnish and Magento need the correct Host header to work properly — using the wrong domain will result in 302 redirects or cache misses.
Magento can generate a VCL file tailored to its caching requirements. This is much better than writing one from scratch — it handles edge cases like form keys, customer sessions, and currency switching correctly.
cd /var/www/magento
php bin/magento varnish:vcl:generate \
--backend-host=host.docker.internal \
--backend-port=8080 \
--access-list=127.0.0.1 \
--grace-period=300 \
--output-file=/home/magento/docker/varnish/default.vclCode language: JavaScript (javascript)
Let’s break down the parameters:
--backend-host=host.docker.internal — this is the address Varnish uses to reach Nginx on the host. Remember, Varnish runs in Docker, so it can’t use 127.0.0.1 to reach the host — it needs host.docker.internal, which we mapped in the docker-compose.yml with extra_hosts.--backend-port=8080 — the port where our backend Nginx server block listens.--access-list=127.0.0.1 — the IP addresses allowed to send cache purge requests. We’ll add the Docker network range manually in the next step.--grace-period=300 — if the backend is slow or down, Varnish will serve stale cached content for up to 300 seconds (5 minutes). This provides resilience during brief outages or slow deployments.The generated VCL needs one manual adjustment — we need to add the Docker network range to the purge ACL so that Magento (running on the host) can send purge requests that reach Varnish through the Docker network.
nano ~/docker/varnish/default.vclCode language: JavaScript (javascript)
Find the acl purge block near the top. It will look like:
acl purge {
"127.0.0.1";
}Code language: JavaScript (javascript)
Add the Docker network range so it becomes:
acl purge {
"127.0.0.1";
"172.16.0.0"/12;
}Code language: JavaScript (javascript)
The 172.16.0.0/12 range covers Docker’s internal network addresses, allowing purge requests from the host to reach Varnish inside its container.
Also check that the backend definition near the top looks like:
backend default {
.host = "host.docker.internal";
.port = "8080";
.first_byte_timeout = 600s;
...
}Code language: JavaScript (javascript)
If first_byte_timeout is missing or set low, add or adjust it — Magento can be slow on uncached requests, and you don’t want Varnish timing out.
Also check the health probe section. The generated VCL will include a probe like:
.probe = {
.url = "/health_check.php";
.timeout = 2s;
.interval = 5s;
.window = 10;
.threshold = 5;
}Code language: JavaScript (javascript)
This needs to be changed to include a Host header — without it, the probe request won’t have the correct hostname and Magento may reject it. Replace the .url line with a .request block that includes your domain:
.probe = {
.request =
"GET /health_check.php HTTP/1.1"
"Host: yourdomain.com"
"Connection: close";
.timeout = 2s;
.interval = 5s;
.window = 10;
.threshold = 5;
}Code language: JavaScript (javascript)
Replace yourdomain.com with your actual domain.
Varnish (running in Docker) needs to reach Nginx on the host via port 8080. By default, UFW blocks this traffic because it treats requests from the Docker bridge network as incoming connections. Add a rule to allow it:
sudo ufw allow from 172.16.0.0/12 to any port 8080 comment "Docker to Nginx backend"
sudo ufw reloadCode language: JavaScript (javascript)
This allows traffic from any Docker container to reach the backend Nginx port. It’s safe because port 8080 is not exposed to the internet — only Docker’s internal network can reach it.
cd ~/docker
docker compose restart varnish
Check that Varnish started without errors:
docker logs magento-varnish --tail 20
You should see Varnish starting up cleanly with no VCL compilation errors. If there are errors, they’ll reference specific line numbers in the VCL — check those lines in ~/docker/varnish/default.vcl.
Magento needs to know it should use Varnish as the full page cache backend instead of the built-in cache. Set this via CLI:
cd /var/www/magento
php bin/magento config:set system/full_page_cache/caching_application 2Code language: JavaScript (javascript)
The value 2 means Varnish. (Value 1 is the built-in full page cache.)
Flush the cache so Magento picks up the change:
php bin/magento cache:flush
Next, configure where Magento sends purge requests. This is the critical setting that tells Magento to send PURGE requests to Varnish when content changes:
php bin/magento setup:config:set --http-cache-hosts=127.0.0.1:6081Code language: JavaScript (javascript)
This adds the http_cache_hosts entry to app/etc/env.php. Without it, Magento will never send purge requests to Varnish — saving products or CMS pages in the admin won’t invalidate the cached version, and cache:flush full_page will only clear Redis without touching Varnish.
Also set the Varnish configuration in the admin settings:
php bin/magento config:set system/full_page_cache/varnish/access_list 127.0.0.1
php bin/magento config:set system/full_page_cache/varnish/backend_host 127.0.0.1
php bin/magento config:set system/full_page_cache/varnish/backend_port 6081
php bin/magento cache:flushCode language: JavaScript (javascript)
Now let’s test that Varnish is actually caching. We’ll send requests to Varnish on port 6081 and check the response headers. We need to include the Host header matching your domain — without it, Magento will reject the request with a redirect:
<em># First request — should be a MISS (Varnish fetches from backend)</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"
<em># Second request — should be a HIT (Varnish serves from cache)</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"Code language: PHP (php)
What to look for:
X-Magento-Cache-Debug: MISS on the first request — Varnish had to fetch the page from the backend.X-Magento-Cache-Debug: HIT on the second request — Varnish served the page from its cache without touching PHP.Note: If you don’t see
X-Magento-Cache-Debugheaders, you may need to enable them. Run:php bin/magento config:set system/full_page_cache/varnish/debug 1thenphp bin/magento cache:flush. These debug headers are useful during setup but should be disabled in production (set to0) to avoid leaking information.
If you’re getting MISS on every request, common causes are:
Set-Cookie header on every response (logged-in users, or a misconfigured module).docker exec magento-varnish varnishadm backend.list.docker logs magento-varnish.Now that http_cache_hosts is configured, Magento can purge Varnish cache. There are two ways to do it:
1. Magento CLI — php bin/magento cache:flush full_page clears both Redis page cache and sends PURGE requests to Varnish.
2. Varnish admin — docker exec magento-varnish varnishadm "ban req.url ~ /" purges everything directly in Varnish, bypassing Magento.
3. Automatic (admin panel) — when you save a product, CMS page, or other content through the Magento admin, Magento automatically sends targeted PURGE requests to Varnish for the affected URLs.
Let’s test the Magento CLI purge:
<em># Warm the cache</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ > /dev/null
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"
<em># Expected: X-Magento-Cache-Debug: HIT</em>
<em># Purge via Magento</em>
cd /var/www/magento
php bin/magento cache:flush full_page
<em># Check again — should be a MISS now</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"
<em># Expected: X-Magento-Cache-Debug: MISS</em>
<em># Request again — back to HIT</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"
<em># Expected: X-Magento-Cache-Debug: HIT</em>Code language: PHP (php)
If the purge works (HIT → MISS → HIT pattern), your Varnish setup is complete and functional.
<em># Varnish container is running</em>
docker ps | grep varnish
<em># Expected: magento-varnish is Up</em>
<em># Varnish backend is healthy</em>
docker exec magento-varnish varnishadm backend.list
<em># Expected: Health column shows "healthy"</em>
<em># Varnish responds with 200</em>
curl -s -o /dev/null -w "%{http_code}" -H "Host: yourdomain.com" http://127.0.0.1:6081/
<em># Expected: 200</em>
<em># Cache debug headers are present</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"
<em># Expected: X-Magento-Cache-Debug: HIT or MISS</em>
<em># Varnish is configured as FPC in Magento</em>
cd /var/www/magento
php bin/magento config:show system/full_page_cache/caching_application
<em># Expected: 2</em>
<em># Purge works — run this sequence:</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ > /dev/null <em># warm the cache</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache" <em># should be HIT</em>
cd /var/www/magento && php bin/magento cache:flush full_page <em># purge via Magento</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache" <em># should be MISS</em>
<em># Expected: HIT then MISS confirms purge is working</em>
<em># http_cache_hosts is configured in env.php</em>
php -r "\$c = include '/var/www/magento/app/etc/env.php'; print_r(\$c['http_cache_hosts']);"
<em># Expected: array with host 127.0.0.1 and port 6081</em>
<em># No VCL errors in logs</em>
docker logs magento-varnish --tail 5
<em># Expected: clean output, no error messages</em>Code language: HTML, XML (xml)
Come back to this after Step 11 (Cloudflare): Once you can access the Magento admin in a browser, verify that automatic purging works for real content changes:
- Visit a CMS page (e.g., the homepage) in your browser — this warms the Varnish cache.
- Open an SSH terminal and confirm the page is cached:
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"— should show HIT.- In the Magento admin, edit that CMS page (change a word, anything) and save it.
- Check again with the same curl command — should now show MISS, meaning Magento automatically told Varnish to purge that page.
- Visit the page in your browser again — the updated content should appear, and subsequent curl checks should show HIT.
If you’re still seeing HIT after saving content in the admin, check that
http_cache_hostsis set correctly inapp/etc/env.phpand that the Varnish purge ACL includes the right IP ranges.
Varnish is generating cache HITs and purging correctly. On to Step 10 — configuring the Nginx SSL proxy to complete the traffic flow.
This is the final piece that connects the outside world to your Magento stack. We’re setting up the Nginx server block on port 443 that terminates SSL and proxies traffic to Varnish. After this step and Cloudflare configuration in Step 11, your store will be accessible in a browser.
The traffic flow we’re completing:
Cloudflare → Nginx (443, SSL termination) → Varnish (6081) → Nginx (8080) → PHP-FPM
Since we’re using Cloudflare in front of the server, we’ll use a Cloudflare Origin Certificate for the connection between Cloudflare and Nginx. This is a free certificate that Cloudflare issues specifically for origin servers — it’s trusted by Cloudflare but not by browsers directly. That’s fine because browsers never talk to your server directly; they always go through Cloudflare.
We’ll generate the origin certificate in Step 11 (Cloudflare Configuration). For now, let’s create the Nginx server block and use a self-signed certificate as a placeholder so we can test the proxy configuration.
This is just for testing the Nginx configuration. We’ll replace it with the Cloudflare Origin Certificate in Step 11.
sudo mkdir -p /etc/nginx/ssl
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/ssl/origin.key \
-out /etc/nginx/ssl/origin.crt \
-subj "/CN=yourdomain.com"Code language: JavaScript (javascript)
Replace yourdomain.com with your actual domain — if you’re using a subdomain (e.g., store.example.com), use the full subdomain here. This must match what you configured as Magento’s base URL.
Create the SSL proxy configuration:
sudo nano /etc/nginx/sites-available/magento-ssl
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/nginx/ssl/origin.crt;
ssl_certificate_key /etc/nginx/ssl/origin.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
<em># Proxy all traffic to Varnish</em>
location / {
proxy_pass http://127.0.0.1:6081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
<em># Timeouts</em>
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
<em># Buffer settings</em>
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}Code language: JavaScript (javascript)
Replace yourdomain.com with your actual domain in both server blocks.
Let’s walk through what this does:
listen 443 ssl http2 — listens for HTTPS connections with HTTP/2 support.proxy_pass http://127.0.0.1:6081 — sends all traffic to Varnish.proxy_set_header Host $host — passes the original domain name to Varnish. This is critical — without it, Varnish would receive the request with Host: 127.0.0.1 and Magento would redirect.proxy_set_header X-Forwarded-Proto https — tells the backend the original request was HTTPS.proxy_set_header X-Forwarded-For — passes the real client IP through to Magento.sudo ln -s /etc/nginx/sites-available/magento-ssl /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Test the full chain from the server itself:
<em># Test via HTTPS (using -k to accept the self-signed cert)</em>
curl -s -k -o /dev/null -w "%{http_code}" https://127.0.0.1/ -H "Host: yourdomain.com"
<em># Expected: 200</em>
<em># Confirm Varnish is in the chain</em>
curl -s -k -I https://127.0.0.1/ -H "Host: yourdomain.com" | grep -i "X-Magento-Cache"
<em># Expected: x-magento-cache-debug: HIT or MISS (lowercase because HTTP/2)</em>
<em># Confirm HTTP redirects to HTTPS</em>
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/ -H "Host: yourdomain.com"
<em># Expected: 301</em>Code language: PHP (php)
If you get a 200 on HTTPS with the X-Magento-Cache-Debug header present, the full traffic flow is working: Nginx (443) → Varnish (6081) → Nginx (8080) → PHP-FPM.
<em># Nginx config is valid</em>
sudo nginx -t
<em># Expected: syntax is ok, test is successful</em>
<em># HTTPS returns 200</em>
curl -s -k -o /dev/null -w "%{http_code}" https://127.0.0.1/ -H "Host: yourdomain.com"
<em># Expected: 200</em>
<em># Varnish headers are present through HTTPS</em>
curl -s -k -I https://127.0.0.1/ -H "Host: yourdomain.com" | grep -i "X-Magento-Cache"
<em># Expected: x-magento-cache-debug: HIT or MISS (lowercase because HTTP/2)</em>
<em># HTTP redirects to HTTPS</em>
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/ -H "Host: yourdomain.com"
<em># Expected: 301</em>
<em># Both Nginx server blocks are enabled</em>
ls -la /etc/nginx/sites-enabled/
<em># Expected: magento-backend and magento-ssl listed</em>Code language: HTML, XML (xml)
The SSL proxy is working. In Step 11 we’ll replace the self-signed certificate with a Cloudflare Origin Certificate and configure Cloudflare to route traffic to your server. After that, your store will be live and accessible in a browser.
This is the step where your store goes live. We’ll configure Cloudflare to handle DNS, SSL, and edge protection for your Magento store.
If you haven’t already, add your domain to Cloudflare and point your DNS to the server.
In the Cloudflare dashboard:
@ for the root domain (e.g., shop if your store is shop.example.com)The orange cloud (Proxied) means Cloudflare sits between visitors and your server, providing SSL, caching, DDoS protection, and WAF.
This is critical. Go to SSL/TLS → Overview and set the encryption mode to Full (strict).
What the modes mean:
Now we’ll replace the self-signed certificate with a Cloudflare Origin Certificate that Cloudflare trusts.
example.com and *.example.com)On your server, replace the self-signed certificate files:
sudo nano /etc/nginx/ssl/origin.crt
Paste the Origin Certificate content (the one that starts with -----BEGIN CERTIFICATE-----).
sudo nano /etc/nginx/ssl/origin.key
Paste the Private Key content (the one that starts with -----BEGIN PRIVATE KEY-----).
Reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
By default, Nginx sees Cloudflare’s IP addresses in its access logs, not your visitors’ real IPs. To restore the original client IP, we need to configure Nginx to trust Cloudflare’s IP ranges.
Create a Cloudflare real IP configuration:
sudo nano /etc/nginx/conf.d/cloudflare-real-ip.conf
<em># Docker/Varnish network — requests arrive at the backend via Varnish in Docker</em>
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 127.0.0.1;
<em># Cloudflare IPv4 ranges</em>
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
<em># Cloudflare IPv6 ranges</em>
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP;Code language: HTML, XML (xml)
Note: Cloudflare’s IP ranges can change over time. Check https://www.cloudflare.com/ips/ for the latest list. It’s a good idea to update this file periodically.
Reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
While you’re in the Cloudflare dashboard, configure these recommended settings:
SSL/TLS → Edge Certificates:
Security → Settings:
Speed → Content Optimization:
Caching → Configuration:
Important about Cloudflare caching: By default, Cloudflare caches static assets (images, CSS, JS) at the edge — close to your visitors. This works alongside Varnish, not instead of it. Varnish caches full HTML pages on your server, while Cloudflare handles static files. Don’t create custom Cache Rules (under Rules → Cache Rules) that cache HTML responses for your Magento store — it will cache dynamic pages and cause issues with customer sessions, cart contents, and logged-in states. The default Cloudflare caching behavior is exactly what you want.
Open your store in a browser — it should load over HTTPS with a valid certificate.
<em># From your server — confirm Cloudflare is in the chain</em>
curl -s -I https://yourdomain.com | head -20
<em># Expected: HTTP/2 200, with cf-ray and cf-cache-status headers</em>
<em># Verify the certificate is issued by Cloudflare's edge (not your self-signed cert)</em>
curl -s -v https://yourdomain.com 2>&1 | grep "issuer:"
<em># Expected: issuer containing "Let's Encrypt", "Google Trust Services", or "Cloudflare" — this is the edge certificate Cloudflare presents to visitors, not your origin cert</em>
<em># Verify real IP restoration is working</em>
<em># Visit your store in a browser, then check the Nginx access log</em>
sudo tail -5 /var/log/nginx/magento-backend-access.log
<em># Expected: you should see your real IP address, not a Cloudflare IP (173.x, 104.x, etc.)</em>Code language: PHP (php)
Also check in your browser:
https://yourdomain.com/your_admin_pathNow go back and complete the deferred Varnish test from Step 9: Edit a CMS page in the admin, save it, and verify the Varnish cache is purged for that page.
Your store is now live and accessible to the world. The remaining steps are about operational readiness — cron jobs, performance validation, backups, and monitoring.
Magento relies heavily on cron jobs for background tasks. Without cron, indexers won’t run on schedule, emails won’t be sent, queue consumers won’t process messages, and many admin features will simply not work.
Magento has three cron groups that handle different types of tasks:
Magento cron must run as the www-data user — the same user that PHP-FPM runs as. Running cron as root or your magento user will create files with wrong ownership, which causes permission errors.
Set up the crontab for www-data:
sudo crontab -u www-data -e
Add these three lines:
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=default 2>&1 | /usr/bin/grep -v "Ran jobs by schedule" >> /var/www/magento/var/log/magento-cron.log
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=index 2>&1 | /usr/bin/grep -v "Ran jobs by schedule" >> /var/www/magento/var/log/magento-cron.log
* * * * * /usr/bin/php /var/www/magento/bin/magento cron:run --group=consumers 2>&1 | /usr/bin/grep -v "Ran jobs by schedule" >> /var/www/magento/var/log/magento-cron.logCode language: JavaScript (javascript)
Let’s break this down:
* * * * * — runs every minute. Magento’s cron dispatcher runs every minute and internally decides which scheduled tasks are due.--group=default|index|consumers — each group is run separately so they don’t block each other. A slow indexer reindex won’t prevent emails from being sent.grep -v "Ran jobs by schedule" — filters out the noisy success message that Magento logs on every run. Without this filter, the log file fills up with useless “Ran jobs by schedule” lines every minute.>> /var/www/magento/var/log/magento-cron.log — logs errors and output to a Magento log file for debugging.Make sure the log file exists and is writable:
sudo touch /var/www/magento/var/log/magento-cron.log
sudo chown www-data:www-data /var/www/magento/var/log/magento-cron.logCode language: JavaScript (javascript)
Wait about 2 minutes after setting up the crontab, then check:
<em># Verify the crontab is installed</em>
sudo crontab -u www-data -l
<em># Expected: three cron:run lines</em>
<em># Check if cron has run recently</em>
sudo tail -20 /var/www/magento/var/log/magento-cron.log
<em># Expected: may be empty (good — means no errors) or show some job output</em>
<em># Run once manually as www-data to verify it works</em>
cd /var/www/magento
sudo -u www-data php bin/magento cron:run --group=default
<em># Expected: completes without errors</em>Code language: HTML, XML (xml)
You can also check the database for cron schedule records:
docker exec magento-mariadb mariadb -u magento -p'your_magento_db_password_here' magento \
-e "SELECT job_code, status, executed_at FROM cron_schedule ORDER BY schedule_id DESC LIMIT 10;"Code language: JavaScript (javascript)
You should see rows with status of success and recent executed_at timestamps.
Running cron as the wrong user is the most common issue. If you see permission errors in the Magento admin or files owned by root in var/ and generated/, check that cron is running as www-data:
<em># This is WRONG — don't do this</em>
<em># sudo crontab -e (installs as root)</em>
<em># crontab -e (installs as your magento user)</em>
<em># This is CORRECT</em>
sudo crontab -u www-data -eCode language: HTML, XML (xml)
If you’ve already run cron as the wrong user, fix the permissions:
sudo chown -R www-data:www-data /var/www/magento/var/ /var/www/magento/generated/Code language: JavaScript (javascript)
<em># Crontab is installed for www-data</em>
sudo crontab -u www-data -l
<em># Expected: three cron:run lines for default, index, and consumers groups</em>
<em># Manual cron run succeeds</em>
cd /var/www/magento
sudo -u www-data php bin/magento cron:run --group=default
<em># Expected: completes without errors</em>
<em># Cron schedule table has entries</em>
docker exec magento-mariadb mariadb -u magento -p'your_magento_db_password_here' magento \
-e "SELECT job_code, status, executed_at FROM cron_schedule ORDER BY schedule_id DESC LIMIT 10;"
<em># Expected: rows with recent timestamps and 'success' status (some 'pending' rows are normal — those are scheduled for upcoming runs)</em>
<em># No permission errors in Magento logs</em>
tail -20 /var/www/magento/var/log/magento-cron.log
<em># Expected: empty or minimal output (errors would show here)</em>
<em># Indexers are processing on schedule (may take a few minutes after cron starts)</em>
php bin/magento indexer:status
<em># Expected: all indexers show "Ready" or "Processing"</em>Code language: HTML, XML (xml)
Cron is running and Magento’s scheduled tasks are being processed. On to Step 13 — performance validation.
Everything is installed, configured, and running. This step is about confirming that all the performance layers are actually doing their job. Think of it as a health check across the entire stack.
<em># Check the HIT ratio — first request warms the cache, second should be a HIT</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ > /dev/null
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"
<em># Expected: HIT</em>
<em># Check Varnish statistics</em>
docker exec magento-varnish varnishstat -1 | grep -E "MAIN.cache_hit|MAIN.cache_miss"
<em># Expected: cache_hit should be present. On a fresh setup, misses may outnumber hits due to testing — the ratio improves with real traffic.</em>Code language: PHP (php)
If your hit ratio is low, common causes are:
<em># Cache Redis — memory usage and hit rate</em>
docker exec magento-redis-cache redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
<em># Expected: hits should be much higher than misses</em>
docker exec magento-redis-cache redis-cli INFO memory | grep "used_memory_human"
<em># Expected: well below the 512mb maxmemory limit</em>
<em># Sessions Redis — active sessions</em>
docker exec magento-redis-session redis-cli DBSIZE
<em># Expected: a number corresponding to active sessions</em>
<em># Check for evictions (keys being dropped because memory is full)</em>
docker exec magento-redis-cache redis-cli INFO stats | grep "evicted_keys"
<em># Expected: 0 or a very low number. High evictions mean you need more memory.</em>Code language: PHP (php)
<em># Cluster health</em>
curl -s http://127.0.0.1:9200/_cluster/health?pretty | grep -E "status|number_of_nodes"
<em># Expected: status green or yellow, 1 node</em>
<em># Index sizes</em>
curl -s http://127.0.0.1:9200/_cat/indices?v | grep magento2
<em># Expected: at least one index listed. docs.count will be 0 on a fresh store with no products — that's normal.</em>
<em># JVM heap usage</em>
curl -s http://127.0.0.1:9200/_nodes/stats/jvm?pretty | grep -E "heap_used_percent"
<em># Expected: below 75%. On a fresh store this may be higher due to baseline overhead — monitor this again after adding products and reindexing. If it stays consistently above 80%, increase the JVM heap in docker-compose.yml (-Xms1024m -Xmx1024m) and bump mem_limit accordingly.</em>Code language: PHP (php)
<em># Queue status</em>
curl -s -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/queues | python3 -m json.tool | grep -E "\"name\"|\"messages\""
<em># Expected: queues listed with 0 or low message counts. High message counts mean consumers aren't keeping up.</em>
<em># Overall health</em>
curl -s -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/healthchecks/node
<em># Expected: {"status":"ok"}</em>Code language: HTML, XML (xml)
OPcache runs inside PHP-FPM, not the CLI, so we can’t check its status from the command line directly (we set opcache.enable_cli=0). Instead, verify it’s configured correctly:
<em># Confirm OPcache is loaded in FPM</em>
php-fpm8.3 -i 2>/dev/null | grep -E "opcache.enable |opcache.memory_consumption|opcache.validate_timestamps"
<em># Expected: opcache.enable => On, memory_consumption => 512, validate_timestamps => Off</em>
<em># Confirm OPcache module is loaded</em>
php -m | grep OPcache
<em># Expected: Zend OPcache</em>Code language: HTML, XML (xml)
To verify OPcache is actually working in FPM, check that cached page response times are fast — a Varnish MISS that takes under 1-2 seconds means PHP is serving compiled bytecode from OPcache rather than parsing files from scratch every time.
<em># Memory overview</em>
free -h
<em># Expected: several GB still available. If "available" is below 1-2GB, you may be overcommitted.</em>
<em># Docker container resource usage</em>
docker stats --no-stream
<em># Expected: all containers running, none hitting their memory limits</em>
<em># Disk usage</em>
df -h /
<em># Expected: plenty of free space. Magento generates a lot of logs and cache files over time.</em>
<em># PHP-FPM process count</em>
ps aux | grep php-fpm | grep -v grep | wc -l
<em># Expected: a number between pm.min_spare_servers and pm.max_children</em>Code language: HTML, XML (xml)
<em># Cached page (through Varnish) — should be very fast</em>
time curl -s -o /dev/null -H "Host: yourdomain.com" http://127.0.0.1:6081/
<em># Expected: real time well under 0.1s for a cached page</em>
<em># Uncached page (bypassing Varnish, hitting PHP directly)</em>
time curl -s -o /dev/null -H "Host: yourdomain.com" http://127.0.0.1:8080/
<em># Expected: real time under 1-2s for a fresh Magento page (varies by server spec)</em>Code language: PHP (php)
The difference between these two numbers shows exactly what Varnish is doing for you. A cached Varnish response should be 10-100x faster than hitting PHP.
This is a summary check. If any of these fail, revisit the relevant step:
<em># All Docker containers running</em>
docker ps --format "table {{.Names}}\t{{.Status}}" | grep magento
<em># Expected: all 6 containers showing "Up"</em>
<em># Varnish is caching</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep "X-Magento-Cache"
<em># Expected: HIT</em>
<em># Redis is populated</em>
docker exec magento-redis-cache redis-cli DBSIZE
<em># Expected: number greater than 0</em>
<em># OpenSearch is healthy</em>
curl -s http://127.0.0.1:9200/_cluster/health | python3 -m json.tool | grep status
<em># Expected: green or yellow</em>
<em># RabbitMQ is healthy</em>
curl -s -u magento:your_rabbitmq_password_here http://127.0.0.1:15672/api/healthchecks/node
<em># Expected: {"status":"ok"}</em>
<em># Cron is running</em>
docker exec magento-mariadb mariadb -u magento -p'your_magento_db_password_here' magento \
-e "SELECT COUNT(*) as recent_jobs FROM cron_schedule WHERE executed_at > NOW() - INTERVAL 5 MINUTE;"
<em># Expected: a number greater than 0</em>
<em># PHP-FPM is running</em>
sudo systemctl status php8.3-fpm | grep Active
<em># Expected: active (running)</em>
<em># No critical errors in Magento logs</em>
tail -20 /var/www/magento/var/log/system.log | grep -i "critical\|emergency"
<em># Expected: no output (no critical errors)</em>
tail -20 /var/www/magento/var/log/exception.log
<em># Expected: no output or no recent entries. If you see exceptions, investigate before going live.</em>Code language: HTML, XML (xml)
If everything checks out, your stack is performing well. On to Step 14 — backup and disaster recovery.
Your store is running, but without backups you’re one bad deployment or disk failure away from losing everything. This step sets up a layered backup strategy: database dumps, Docker volume backups, file backups, and server snapshots.
The database is the most critical thing to back up — it contains all your products, orders, customers, and configuration. A corrupt or lost database means starting from scratch.
Create a backup script:
nano ~/scripts/backup-database.shCode language: JavaScript (javascript)
#!/bin/bash
<em># Database backup script with tiered retention</em>
BACKUP_DIR="/home/magento/backups/database"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DAY_OF_MONTH=$(date +%d)
mkdir -p "$BACKUP_DIR/daily" "$BACKUP_DIR/monthly"
<em># Dump the database</em>
docker exec magento-mariadb mariadb-dump \
-u magento \
-p'your_magento_db_password_here' \
--single-transaction \
--routines \
--triggers \
magento | gzip > "$BACKUP_DIR/daily/magento_${TIMESTAMP}.sql.gz"
<em># Check if the dump was successful</em>
if [ $? -eq 0 ]; then
echo "$(date): Database backup successful: magento_${TIMESTAMP}.sql.gz"
else
echo "$(date): DATABASE BACKUP FAILED!" >&2
exit 1
fi
<em># On the 1st of each month, copy to monthly folder</em>
if [ "$DAY_OF_MONTH" = "01" ]; then
cp "$BACKUP_DIR/daily/magento_${TIMESTAMP}.sql.gz" "$BACKUP_DIR/monthly/"
echo "$(date): Monthly backup copy created."
fi
<em># Retention: daily backups kept for 14 days, monthly backups kept for 90 days</em>
find "$BACKUP_DIR/daily" -name "*.sql.gz" -mtime +14 -delete
find "$BACKUP_DIR/monthly" -name "*.sql.gz" -mtime +90 -delete
echo "$(date): Cleanup complete."Code language: PHP (php)
Replace your_magento_db_password_here with your actual database password.
Key flags explained:
--single-transaction — takes a consistent snapshot without locking tables. Critical for production — without this, the backup would lock your database and block all writes.--routines — includes stored procedures.--triggers — includes database triggers.Make the script executable and create the directories:
mkdir -p ~/scripts ~/backups/database/daily ~/backups/database/monthly
chmod +x ~/scripts/backup-database.shCode language: JavaScript (javascript)
Test it:
~/scripts/backup-database.sh
ls -lh ~/backups/database/daily/Code language: JavaScript (javascript)
You should see a .sql.gz file. A fresh Magento database compresses to a few MB. With products and orders, expect it to grow.
Schedule it to run daily:
crontab -e
Add:
0 3 * * * /home/magento/scripts/backup-database.sh >> /home/magento/backups/backup.log 2>&1Code language: JavaScript (javascript)
This runs at 3 AM daily.
Docker volumes hold the persistent data for OpenSearch, Redis sessions, and RabbitMQ. While these can be rebuilt (reindex OpenSearch, sessions regenerate, queues re-process), backing them up saves recovery time.
nano ~/scripts/backup-docker-volumes.shCode language: JavaScript (javascript)
#!/bin/bash
<em># Docker volume backup script</em>
BACKUP_DIR="/home/magento/backups/docker-volumes"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=28
mkdir -p "$BACKUP_DIR"
<em># Back up each volume</em>
for VOLUME in docker_mariadb_data docker_opensearch_data docker_redis_session_data docker_rabbitmq_data; do
docker run --rm \
-v ${VOLUME}:/source:ro \
-v ${BACKUP_DIR}:/backup \
alpine tar czf /backup/${VOLUME}_${TIMESTAMP}.tar.gz -C /source .
if [ $? -eq 0 ]; then
echo "$(date): Volume ${VOLUME} backed up successfully."
else
echo "$(date): VOLUME BACKUP FAILED: ${VOLUME}" >&2
fi
done
<em># Cleanup old backups</em>
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete
echo "$(date): Docker volume backup complete."Code language: PHP (php)
Note: The volume names include the directory prefix — if your
docker-compose.ymlis in~/docker, the volumes will be nameddocker_mariadb_data, etc. Check withdocker volume lsand adjust the script if your names differ.
chmod +x ~/scripts/backup-docker-volumes.sh
mkdir -p ~/backups/docker-volumesCode language: JavaScript (javascript)
Schedule it weekly (volumes are large and don’t change as frequently):
crontab -e
Add:
0 4 * * 0 /home/magento/scripts/backup-docker-volumes.sh >> /home/magento/backups/backup.log 2>&1Code language: JavaScript (javascript)
This runs at 4 AM every Sunday.
Back up the Magento files that aren’t in the codebase — uploaded media, configuration, and custom code. Since pub/media/ (product images) is large but rarely changes daily, we split this into a daily code backup and a weekly media backup to save disk space.
Before moving on, let’s make sure your backup strategy fits on disk. Here’s what to expect for a medium-sized store (~5,000 products):
| Backup type | Per backup (compressed) | Copies kept | Total disk |
|---|---|---|---|
| Database | ~100 MB | 14 daily + 3 monthly | ~1.7 GB |
| Magento files (without media) | ~50-200 MB | 14 daily + 3 monthly | ~1-3.4 GB |
| Magento media (pub/media) | ~3-6 GB | 4 weekly | ~12-24 GB |
| Docker volumes | ~3-5 GB | 4 weekly | ~12-20 GB |
| Total | ~27-49 GB |
The key insight is that pub/media/ (product images, uploaded content) is by far the largest component and rarely changes day-to-day. Backing it up daily alongside small config files wastes enormous disk space.
The recommended approach is to split the file backup into two parts — a daily backup for code and config (small, changes often) and a weekly backup for media (large, changes rarely):
nano ~/scripts/backup-files.shCode language: JavaScript (javascript)
#!/bin/bash
<em># Magento files backup script — split into code (daily) and media (weekly)</em>
BACKUP_DIR="/home/magento/backups/files"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DAY_OF_MONTH=$(date +%d)
DAY_OF_WEEK=$(date +%u)
mkdir -p "$BACKUP_DIR/daily" "$BACKUP_DIR/monthly" "$BACKUP_DIR/media"
<em># Daily: code, config, and composer files (small — usually under 200 MB)</em>
PATHS=""
PATHS="$PATHS /var/www/magento/app/etc/env.php"
PATHS="$PATHS /var/www/magento/app/etc/config.php"
PATHS="$PATHS /var/www/magento/composer.json"
PATHS="$PATHS /var/www/magento/composer.lock"
[ -d /var/www/magento/app/code ] && PATHS="$PATHS /var/www/magento/app/code/"
[ -d /var/www/magento/app/design ] && PATHS="$PATHS /var/www/magento/app/design/"
tar czf "$BACKUP_DIR/daily/magento_code_${TIMESTAMP}.tar.gz" $PATHS 2>/dev/null
if [ $? -eq 0 ]; then
echo "$(date): Code backup successful: magento_code_${TIMESTAMP}.tar.gz"
else
echo "$(date): CODE BACKUP FAILED!" >&2
exit 1
fi
<em># Weekly (Sunday): media files</em>
if [ "$DAY_OF_WEEK" = "7" ]; then
tar czf "$BACKUP_DIR/media/magento_media_${TIMESTAMP}.tar.gz" \
--exclude='/var/www/magento/pub/media/cache' \
/var/www/magento/pub/media/ \
2>/dev/null
if [ $? -eq 0 ]; then
echo "$(date): Media backup successful: magento_media_${TIMESTAMP}.tar.gz"
else
echo "$(date): MEDIA BACKUP FAILED!" >&2
fi
<em># Keep 4 weekly media backups</em>
find "$BACKUP_DIR/media" -name "*.tar.gz" -mtime +28 -delete
fi
<em># Monthly: copy code backup on the 1st</em>
if [ "$DAY_OF_MONTH" = "01" ]; then
cp "$BACKUP_DIR/daily/magento_code_${TIMESTAMP}.tar.gz" "$BACKUP_DIR/monthly/"
echo "$(date): Monthly code backup copy created."
fi
<em># Retention: daily code kept for 14 days, monthly kept for 90 days</em>
find "$BACKUP_DIR/daily" -name "*.tar.gz" -mtime +14 -delete
find "$BACKUP_DIR/monthly" -name "*.tar.gz" -mtime +90 -delete
echo "$(date): File backup cleanup complete."Code language: PHP (php)
chmod +x ~/scripts/backup-files.sh
mkdir -p ~/backups/files/daily ~/backups/files/monthly ~/backups/files/mediaCode language: JavaScript (javascript)
With this split approach, the storage footprint for a medium store stays under 50 GB — leaving plenty of room on a 160 GB disk for the OS, Magento installation, Docker images, and growth.
Important: If your
pub/mediadirectory grows very large (tens of GB), consider offsite-only media backups. Sync media to cloud storage rather than keeping multiple copies on the same disk. The daily code + config backup is small enough to always keep locally.
Schedule it daily (the script internally handles weekly media backups):
crontab -e
Add:
30 3 * * * /home/magento/scripts/backup-files.sh >> /home/magento/backups/backup.log 2>&1Code language: JavaScript (javascript)
We enabled daily backups with your VPS provider in Step 1 — these are full server snapshots that capture everything. They’re your last line of defence if something goes catastrophically wrong.
Provider snapshots are good for disaster recovery, but they have limitations:
That’s why we have the granular backups above — they let you restore specific components without rolling back everything.
Backups on the same server as your data aren’t true backups — if the server’s disk fails, you lose both. For production, copy backups offsite.
The example below is a starting point — your actual offsite strategy will depend on your infrastructure (another server, AWS S3, Backblaze B2, Google Cloud Storage, etc.). Plan and implement this before going live with real customer data.
nano ~/scripts/backup-offsite.shCode language: JavaScript (javascript)
#!/bin/bash
<em># Sync backups to offsite location</em>
<em># Uncomment and configure ONE of the options below:</em>
<em># Option 1: rsync to another server (requires SSH key access)</em>
<em># rsync -az --delete ~/backups/ user@backup-server:/backups/magento/</em>
<em># Option 2: sync to cloud storage (requires rclone installed and configured)</em>
<em># rclone sync ~/backups/ remote:magento-backups/</em>
echo "$(date): Offsite backup sync complete."Code language: HTML, XML (xml)
Note: This script is an example template — it won’t do anything until you uncomment and configure one of the options. Offsite backups should be scheduled via cron to run after your local backups complete. Whatever solution you choose, test the restore process before you need it.
For reference, here’s how to restore each component:
Database:
gunzip < ~/backups/database/daily/magento_TIMESTAMP.sql.gz | \
docker exec -i magento-mariadb mariadb -u magento -p'your_magento_db_password_here' magentoCode language: JavaScript (javascript)
Docker volumes:
docker compose -f ~/docker/docker-compose.yml down
docker run --rm \
-v docker_mariadb_data:/target \
-v /home/magento/backups/docker-volumes:/backup \
alpine sh -c "cd /target && tar xzf /backup/docker_mariadb_data_TIMESTAMP.tar.gz"
docker compose -f ~/docker/docker-compose.yml up -dCode language: PHP (php)
Magento files:
tar xzf ~/backups/files/magento_files_TIMESTAMP.tar.gz -C /Code language: JavaScript (javascript)
<em># Database backup script works</em>
~/scripts/backup-database.sh
ls -lh ~/backups/database/daily/
<em># Expected: at least one .sql.gz file</em>
<em># File backup script works</em>
~/scripts/backup-files.sh
ls -lh ~/backups/files/daily/
<em># Expected: at least one .tar.gz file for code/config</em>
<em># Docker volume backup script works (this may take a few minutes)</em>
~/scripts/backup-docker-volumes.sh
ls -lh ~/backups/docker-volumes/
<em># Expected: .tar.gz files for each volume</em>
<em># Crontab has backup entries</em>
crontab -l
<em># Expected: three backup entries (database, files, docker volumes)</em>
<em># Verify a database backup is valid (quick check)</em>
gunzip -t ~/backups/database/daily/$(ls -t ~/backups/database/daily/ | head -1)
<em># Expected: no errors (the file is valid gzip)</em>
<em># VPS provider daily backups are enabled</em>
<em># Check your provider's dashboard — this can't be verified from the command line</em>Code language: HTML, XML (xml)
Backups are in place. On to Step 15 — monitoring and production hardening.
Your store is live, backed up, and performing well. This final step covers the operational tasks that keep it that way: log management, uptime monitoring, disk monitoring, and a deployment script that automates the upgrade process safely.
Magento and Nginx generate logs continuously. Without rotation, they’ll eventually fill your disk.
Nginx logs are already handled by Ubuntu’s default logrotate configuration. Check that it’s in place:
cat /etc/logrotate.d/nginx
You should see a rotation config. If not, create one — but Ubuntu’s nginx package includes this by default.
For Magento logs, create a logrotate configuration:
sudo nano /etc/logrotate.d/magento
/var/www/magento/var/log/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 664 www-data www-data
sharedscripts
}Code language: JavaScript (javascript)
This rotates Magento logs daily, keeps 14 days of compressed history, and creates new log files with the correct ownership.
Also add rotation for our custom cron log:
sudo nano /etc/logrotate.d/magento-cron
/var/www/magento/var/log/magento-cron.log {
weekly
missingok
rotate 4
compress
delaycompress
notifempty
create 664 www-data www-data
}Code language: JavaScript (javascript)
You need to know when your store goes down before your customers tell you. There are several free and paid options:
At minimum, set up a free UptimeRobot monitor that checks https://yourdomain.com every 5 minutes. This catches the majority of outage scenarios.
For a quick self-hosted health check, you can verify the key endpoints:
nano ~/scripts/healthcheck.shCode language: JavaScript (javascript)
#!/bin/bash
<em># Quick health check script</em>
DOMAIN="yourdomain.com"
ERRORS=0
<em># Check Magento frontend</em>
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $DOMAIN" http://127.0.0.1:6081/health_check.php)
if [ "$HTTP_CODE" != "200" ]; then
echo "$(date): ALERT - Frontend returned $HTTP_CODE" >&2
ERRORS=$((ERRORS + 1))
fi
<em># Check all Docker containers are running</em>
EXPECTED_CONTAINERS=6
RUNNING=$(docker ps --filter "name=magento-" --format "{{.Names}}" | wc -l)
if [ "$RUNNING" -lt "$EXPECTED_CONTAINERS" ]; then
echo "$(date): ALERT - Only $RUNNING/$EXPECTED_CONTAINERS containers running" >&2
ERRORS=$((ERRORS + 1))
fi
<em># Check disk space (alert if over 85%)</em>
DISK_USAGE=$(df / --output=pcent | tail -1 | tr -d ' %')
if [ "$DISK_USAGE" -gt 85 ]; then
echo "$(date): ALERT - Disk usage at ${DISK_USAGE}%" >&2
ERRORS=$((ERRORS + 1))
fi
<em># Check PHP-FPM is running</em>
if ! systemctl is-active --quiet php8.3-fpm; then
echo "$(date): ALERT - PHP-FPM is not running" >&2
ERRORS=$((ERRORS + 1))
fi
if [ "$ERRORS" -eq 0 ]; then
echo "$(date): All health checks passed."
fiCode language: PHP (php)
chmod +x ~/scripts/healthcheck.shCode language: JavaScript (javascript)
Schedule it to run every 10 minutes:
crontab -e
Add:
*/10 * * * * /home/magento/scripts/healthcheck.sh >> /home/magento/backups/healthcheck.log 2>&1Code language: JavaScript (javascript)
Check the log periodically for alerts:
grep "ALERT" ~/backups/healthcheck.logCode language: JavaScript (javascript)
Keep an eye on disk usage — Magento logs, database growth, and backups all accumulate:
<em># Current disk usage overview</em>
df -h /
du -sh ~/backups/*
du -sh /var/www/magento/var/log/
du -sh /var/www/magento/pub/media/Code language: HTML, XML (xml)
For ongoing monitoring, the health check script above alerts at 85% disk usage. You can also add a monthly cleanup of Magento’s generated reports:
<em># Clean up old Magento reports (these accumulate in var/report/)</em>
find /var/www/magento/var/report/ -type f -mtime +30 -delete 2>/dev/nullCode language: PHP (php)
Throughout this guide we’ve been running the same sequence of commands after every code change: upgrade, compile, deploy static content, flush cache, reload PHP-FPM, fix permissions. Let’s formalize that into a reusable deploy script:
nano ~/scripts/deploy.shCode language: JavaScript (javascript)
#!/bin/bash
<em># Magento deployment script</em>
set -e
MAGENTO_ROOT="/var/www/magento"
cd "$MAGENTO_ROOT"
echo "$(date): Starting deployment..."
<em># Enable maintenance mode</em>
php bin/magento maintenance:enable
echo "Maintenance mode enabled."
<em># Install composer dependencies (skip dev packages in production)</em>
composer install --no-dev --no-interaction
echo "Composer install complete."
<em># Run database upgrades</em>
php bin/magento setup:upgrade
echo "Setup upgrade complete."
<em># Compile dependency injection</em>
php bin/magento setup:di:compile
echo "DI compilation complete."
<em># Deploy static content</em>
php bin/magento setup:static-content:deploy -f
echo "Static content deployed."
<em># Flush all caches (including Varnish via http_cache_hosts)</em>
php bin/magento cache:flush
echo "Caches flushed."
<em># Reload PHP-FPM to clear OPcache</em>
sudo systemctl reload php8.3-fpm
echo "PHP-FPM reloaded (OPcache cleared)."
<em># Fix file ownership and permissions</em>
sudo chown -R magento:www-data var/ generated/ pub/static/ pub/media/ app/etc/
sudo chmod -R 770 var/ generated/ pub/static/ pub/media/ app/etc/
echo "Permissions fixed."
<em># Disable maintenance mode</em>
php bin/magento maintenance:disable
echo "Maintenance mode disabled."
echo "$(date): Deployment complete!"Code language: PHP (php)
chmod +x ~/scripts/deploy.sh
ln -s ~/scripts/deploy.sh /var/www/magento/bin/deployCode language: JavaScript (javascript)
Now you can run it from the Magento root:
cd ~/www
bin/deploy
The set -e at the top means the script stops immediately if any command fails — you won’t accidentally disable maintenance mode with a half-compiled store.
When to use this script:
composer require to add or update extensionsReminder: This script reloads PHP-FPM to clear OPcache — this is critical when
opcache.validate_timestamps=0. Without this step, code changes won’t take effect even after recompilation. We covered this in detail in Step 3 (OPcache Production Tuning).
During setup we used the X-Magento-Cache-Debug header to verify Varnish was working. In production, disable it to avoid leaking cache information.
Edit the VCL:
nano ~/docker/varnish/default.vclCode language: JavaScript (javascript)
Find the unset block near the end of sub vcl_deliver and add a line to remove the debug header:
unset resp.http.X-Magento-Debug;
unset resp.http.X-Magento-Cache-Debug;
unset resp.http.X-Magento-Tags;Code language: CSS (css)
Restart Varnish:
cd ~/docker
docker compose restart varnish
Verify the header is gone:
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep -i "x-magento-cache-debug"
<em># Expected: no output</em>Code language: JavaScript (javascript)
Tip: If you ever need to debug Varnish caching again, remove the
unset resp.http.X-Magento-Cache-Debug;line and restart Varnish. Or add it back temporarily for a single request usingvarnishlog.
<em># Log rotation is configured</em>
ls /etc/logrotate.d/magento
<em># Expected: file exists</em>
<em># Health check script works</em>
~/scripts/healthcheck.sh
<em># Expected: "All health checks passed."</em>
<em># Deploy script is executable</em>
ls -la ~/scripts/deploy.sh
<em># Expected: permissions show 'x' (executable) for the owner</em>
<em># Varnish debug headers are disabled</em>
curl -s -I -H "Host: yourdomain.com" http://127.0.0.1:6081/ | grep -i "x-magento-cache-debug"
<em># Expected: no output (header is no longer present)</em>
<em># Crontab has all entries</em>
crontab -l
<em># Expected: backup entries + healthcheck entry</em>
<em># Disk usage is healthy</em>
df -h / | tail -1
<em># Expected: plenty of free space</em>Code language: HTML, XML (xml)
Your store is now production-hardened with monitoring, log rotation, automated backups, and a deployment workflow.
These are the issues that come up most frequently when setting up Magento on a VPS. If something isn’t working, check here first.
Varnish runs inside a Docker container. When it tries to reach the backend on localhost or 127.0.0.1, it’s talking to itself — not the host machine where Nginx and PHP-FPM run.
Fix: Use host.docker.internal as the backend host in the VCL, and make sure extra_hosts: - "host.docker.internal:host-gateway" is set on the Varnish service in docker-compose.yml.
If docker exec magento-varnish varnishadm backend.list shows sick, the health probe is failing. Common causes:
172.16.0.0/12 network range to reach the host. Add: sudo ufw allow from 172.16.0.0/12 to any port 8080.request block with the correct Host header, not just a .url. Without it, Magento returns a 302 redirect and Varnish marks the backend as unhealthy.Magento is configured with HTTPS base URLs, but the backend Nginx receives plain HTTP from Varnish. Without telling PHP that the original request was HTTPS, Magento responds with a 302 redirect on every request, making the site unusable and Varnish unable to cache anything.
Fix: In /etc/nginx/fastcgi_params, change:
fastcgi_param HTTPS $https if_not_empty;Code language: PHP (php)
To:
fastcgi_param HTTPS on;
Magento cron must run as www-data — the same user as PHP-FPM. Running it as root or your magento user creates files with wrong ownership in var/, generated/, and pub/static/, leading to permission errors across the entire application.
Fix: Always use sudo crontab -u www-data -e to edit the cron schedule. If you’ve already created files as the wrong user:
sudo chown -R www-data:www-data /var/www/magento/var/ /var/www/magento/generated/Code language: JavaScript (javascript)
OpenSearch requires vm.max_map_count=262144. Without it, the OpenSearch container crashes immediately on startup with no useful error message in docker compose ps — it just shows the container restarting.
Fix:
sudo sysctl -w vm.max_map_count=262144
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.confCode language: PHP (php)
With opcache.validate_timestamps=0 (the recommended production setting), PHP never checks if files have changed on disk. After deploying new code, running setup:di:compile, or setup:static-content:deploy, the old cached bytecode is still served.
Fix: Reload PHP-FPM after every code change:
sudo systemctl reload php8.3-fpmCode language: CSS (css)
This is included in the deploy script (bin/deploy) for exactly this reason.
Docker bypasses UFW by manipulating iptables directly. If you use -p 3306:3306 in your docker-compose.yml, that port is accessible from the internet regardless of your UFW rules.
Fix: Always bind to 127.0.0.1 explicitly: 127.0.0.1:3306:3306. Check your configuration with:
curl -s --connect-timeout 3 http://YOUR_SERVER_IP:3306
curl -s --connect-timeout 3 http://YOUR_SERVER_IP:9200
curl -s --connect-timeout 3 http://YOUR_SERVER_IP:6379Code language: JavaScript (javascript)
All should time out or be refused.
php bin/magento cache:flush full_page only sends PURGE requests to Varnish if http_cache_hosts is configured in app/etc/env.php. The setup:install command does not set this automatically.
Fix:
php bin/magento setup:config:set --http-cache-hosts=127.0.0.1:6081Code language: JavaScript (javascript)
Magento 2.4.8 requires 2FA for admin login. On a fresh install without email configured, the 2FA setup email fails and you’re locked out of the admin entirely.
Fix: Disable 2FA temporarily from CLI:
php bin/magento module:disable Magento_AdminAdobeImsTwoFactorAuth Magento_TwoFactorAuthCode language: JavaScript (javascript)
Remember to re-enable it once you have email working.
If your passwords contain $ characters, Docker Compose interprets them as variable references, causing warnings or broken credentials.
Fix: Either avoid $ in passwords (use openssl rand -base64 24 | tr -d '/+=' to generate safe ones), or escape $ as $$ in the YAML file.
Before considering your store truly production-ready, run through this checklist:
Application:
php bin/magento deploy:mode:show)/admin or /backend)Caching:
http_cache_hosts is set in env.phpvalidate_timestamps=0Search:
Message Queues:
SSL and CDN:
Cron:
www-datacron_schedule table shows recent successful jobsServer:
127.0.0.1Backups:
Monitoring:
Deployment:
bin/deploy) tested and workingCongratulations — you’ve built a production-ready Magento 2.4.8 stack from scratch. Your store is running on a solid foundation: host-based Nginx and PHP-FPM for performance, Docker for service isolation, Varnish for blazing-fast page caching, Redis for application and session caching, OpenSearch for search, RabbitMQ for async processing, and Cloudflare for SSL and edge protection.
The architecture is designed to be maintainable — services are isolated, backups are automated, monitoring is in place, and deployments are scripted. When something needs upgrading or debugging, you know exactly where each piece lives and how it connects.
Keep your system updated, monitor your logs, and test your backups regularly. A production server is never “done” — it’s an ongoing responsibility. But with this foundation, you’re well-equipped to handle it.
