🎉

Revolution wins Sustainable Business of the Year 2026!

How to Set Up Magento on a VPS (Production-Ready Ubuntu + Docker + Varnish + Cloudflare Guide)

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.

What You’ll Need Before Starting

Before you begin, make sure you have the following ready:

  • A VPS provider account â€” this guide uses Hetzner as the example, but the steps apply to DigitalOcean, Vultr, Linode, or any provider offering Ubuntu 24.04 VPS instances. Provider-specific differences are minor and mostly relate to firewall UIs and network configuration.
  • A domain name pointed to your VPS (or ready to be pointed via Cloudflare).
  • A Cloudflare account (free tier works fine for this setup).
  • Magento Marketplace account â€” you’ll need authentication keys to pull Magento via Composer. Create an account at marketplace.magento.com and generate your access keys under My Profile → Access Keys.
  • An SSH key pair for secure server access.

Architecture Overview — Modern Magento Stack

Before we start installing anything, let’s understand what we’re building and why each piece is there.

The Request Flow

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:

  • MariaDB â€” the database
  • OpenSearch â€” powers catalog search and layered navigation
  • Redis (two instances) — one for cache, one for session storage
  • RabbitMQ â€” handles asynchronous message queues

Why This Hybrid Architecture?

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

  • 4+ CPU cores (dedicated, not shared)
  • 16 GB RAM
  • 80+ GB NVMe storage
  • Ubuntu 24.04 LTS

For a serious production store (large catalog, higher traffic, multiple store views):

  • 8+ CPU cores (dedicated)
  • 32 GB RAM
  • 160+ GB NVMe storage
  • Ubuntu 24.04 LTS

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.

Sanity Check Before Proceeding

At this point, confirm you have:

  • A VPS provider account with a server ready to provision (or already provisioned) running Ubuntu 24.04 LTS
  • A domain name you control
  • A Cloudflare account
  • Magento Marketplace access keys (public key + private key)
  • An SSH key pair on your local machine

If any of these are missing, sort them out now. Everything from here assumes these are in place.

Step 1 — Provisioning and Securing Ubuntu

Creating the Server

Spin up a new server with your VPS provider. On Hetzner:

  • Select Ubuntu 24.04 LTS as the OS image.
  • Choose your instance size (4+ dedicated CPU cores and 16 GB RAM minimum for a small store).
  • Select an SSH key during creation — avoid password-based authentication from the start.
  • Choose a datacenter close to your target audience.
  • For the firewall, we’ll configure UFW on the server itself, but Hetzner also offers a cloud firewall in their UI — you can use both. At minimum, allow TCP ports 22, 80, and 443 in the Hetzner firewall if you enable it.
  • Enable daily backups in your provider’s settings. On Hetzner this is a checkbox during server creation (small additional cost). It’s the cheapest insurance you’ll ever buy — if something goes catastrophically wrong, you can restore the entire server to a previous state. We’ll cover a more granular backup strategy later in the guide, but provider-level snapshots are your safety net.

Once the server is created, note the public IP address. You’ll need it shortly.

Initial Server Hardening

SSH into your server as root:

ssh root@YOUR_SERVER_IPCode language: CSS (css)

Create a Sudo User

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)

Disable Root SSH Login

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 magento user login before doing this. Locking yourself out of a remote server is not fun.

Configure UFW Firewall

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.

Install Fail2Ban

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

Configure Timezone and NTP

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)

Installing Docker and Docker Compose

We’ll use Docker’s official repository rather than Ubuntu’s packaged version, which tends to lag behind.

Add Docker’s Official Repository

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)

Install Docker Engine and Compose

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.

Quick Docker Security Note

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.

Sanity Check — Step 1

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.

Step 2 — Docker Infrastructure Setup

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.

Overview of Docker Services

Here’s what each service does and why Magento needs it:

  • MariaDB â€” Magento’s primary database. Stores products, orders, customers, configuration — everything.
  • OpenSearch â€” powers catalog search, layered navigation, and the search suggestions your customers see. Magento dropped Elasticsearch support in favour of OpenSearch.
  • Redis (cache) â€” stores Magento’s configuration cache, layout cache, block HTML cache, and full page cache metadata. Without this, Magento falls back to filesystem caching, which is significantly slower.
  • Redis (sessions) â€” stores customer sessions (login state, cart contents). Using Redis instead of files means sessions survive PHP-FPM restarts and are much faster to read/write.
  • RabbitMQ â€” handles asynchronous message processing. Magento uses it for things like inventory updates, order processing, and bulk operations. It prevents slow operations from blocking the customer-facing request.
  • Varnish â€” a reverse proxy cache that sits in front of Magento and serves cached pages directly without ever hitting PHP. This is what makes Magento fast for anonymous visitors.

Creating the Docker Compose File

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:

ServiceContainer mem_limitKey internal settingNotes
MariaDB2 GBinnodb_buffer_pool_size = 1GBiggest consumer — InnoDB buffer pool is ~50% of limit
OpenSearch2 GBJVM heap 768 MBNeeds headroom beyond JVM for file caches during reindexing
Redis Cache768 MBmaxmemory 512mbDisposable data — no persistence
Redis Sessions512 MBmaxmemory 256mbPersistent (AOF enabled)
RabbitMQ512 MBDefaultLightweight for Magento’s queue workload
Varnish768 MBVARNISH_SIZE 512mCache size + overhead
Docker total~6.5 GB
PHP-FPM (host)~3–4 GBDepends on pool sizeEach worker can use 256–512 MB
Nginx + OS (host)~1–1.5 GB
Headroom~4.5–5.5 GBFor 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 like openssl 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.

MariaDB Configuration

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 Configuration

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

Redis Configuration

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 Configuration

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.

Varnish Configuration (Placeholder)

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.

Starting the Services

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.

Sanity Check — Step 2

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.

Step 3 — Installing Nginx and PHP-FPM 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.

Installing Nginx

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

Nginx Backend Server Block (Port 8080)

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:

  • Port 8080 â€” this is the internal backend port. It’s not exposed to the internet (UFW only allows 22, 80, 443). Varnish connects to it via 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.

Installing PHP 8.3 with Required Extensions

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 sodium extension 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)

Fixing HTTPS Detection for Magento

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.

PHP-FPM Pool Configuration

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.

PHP Configuration (php.ini)

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 Production Tuning

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.

Dealing with OPcache in Production

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.

Restart PHP-FPM

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.

Sanity Check — Step 3

<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 -t will likely fail at this point because /var/www/magento/nginx.conf.sample doesn’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.

Step 4 — Installing Magento 2.4.8 via Composer

Now we install Magento itself. We’ll use Composer to pull the project, configure file permissions, run the installer, and switch to production mode.

Installing Composer Globally

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)

Configuring Magento Marketplace Authentication

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.

Creating the Magento Project

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.8 here. 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.

Setting Correct File Ownership and Permissions

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)

Running the Magento Installer

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

Disabling Two-Factor Authentication (Temporary)

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_TwoFactorAuth followed by php bin/magento setup:upgrade. This is included in the Final Checklist at the end of the guide.

Reload Nginx

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.

Switching to Production Mode

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:

  • Runs setup:di:compile â€” generates all dependency injection classes
  • Runs setup:static-content:deploy â€” compiles and deploys all CSS, JS, and HTML assets
  • Disables automatic code generation

It 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)

Fix Ownership After Compilation

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)

Sanity Check — Step 4

<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.php file 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.

Step 5 — Applying Magento 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.

Checking Your Current Version

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)

Updating to the Latest Patch Version

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-commerce instead of the standard composer require for the metapackage. If you accidentally run composer require, you’ll get an error — just use require-commerce instead.

Note: If there’s no newer patch version than what you installed in Step 4, you can skip the composer require and composer update commands. Run php bin/magento --version to 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)

Sanity Check — Step 5

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

Step 6 — Configuring Redis for Cache and Sessions

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.

How Magento Uses 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.

Verifying Redis Configuration in env.php

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:

  • Default cache → server: 127.0.0.1, port: 6379, database: 0
  • Page cache → server: 127.0.0.1, port: 6379, database: 1
  • Sessions → host: 127.0.0.1, port: 6380, database: 0

Verifying Redis Is Actually Being Used

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

Testing the Cache

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 curl request 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.

Sanity Check — Step 6

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

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.

How Magento Uses OpenSearch

OpenSearch powers everything search-related in your store:

  • Catalog search â€” the search bar your customers use to find products.
  • Layered navigation â€” the filter sidebar on category pages (price ranges, attributes, etc.).
  • Search suggestions â€” autocomplete suggestions as customers type.
  • Catalog sorting â€” “sort by relevance” relies on OpenSearch scoring.

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

Verifying Cluster Health

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:

  • green â€” all primary and replica shards are allocated. Ideal, but on a single-node setup you’ll often see yellow.
  • yellow â€” all primary shards are allocated, but some replicas aren’t. This is normal for a single-node cluster because replicas need a second node. Nothing to worry about.
  • red â€” some primary shards are unallocated. This is a problem and needs investigation.

Checking Magento’s OpenSearch Indices

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.

Verifying the Search Engine Setting in Magento

cd /var/www/magento
php bin/magento config:show catalog/search/engine
<em># Expected: opensearch</em>Code language: HTML, XML (xml)

Indexer Status

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.

Indexer Modes

Each indexer can run in one of two modes:

  • Update on Save â€” indices are updated immediately when you save a product, category, or other entity in the admin. Good for small catalogs, but can slow down admin operations on large stores.
  • Update by Schedule â€” index changes are queued and processed by cron. Better for larger catalogs because it batches updates and doesn’t slow down the admin.

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.

Sanity Check — Step 7

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

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.

How Magento Uses RabbitMQ

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:

  • Inventory updates â€” when an order is placed, stock quantity changes are queued rather than blocking the checkout.
  • Product import/export â€” bulk operations are processed asynchronously.
  • Order processing â€” certain post-order operations happen in the background.
  • Index updates â€” when indexers are set to “Update by Schedule,” changes are queued for batch processing.

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.

Verifying the Connection

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)

Checking Queues

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.

Running Queue Consumers

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.

Sanity Check — Step 8

<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 open http://localhost:15672 in your browser. Press Ctrl+C to 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.

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.com with 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.

Generating the Magento VCL File

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.

Reviewing and Adjusting the Generated VCL

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.

Allow Docker-to-Host Traffic in UFW

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.

Restarting Varnish with the New VCL

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.

Enabling Varnish in Magento Admin

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)

Confirming Cache HIT/MISS

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-Debug headers, you may need to enable them. Run: php bin/magento config:set system/full_page_cache/varnish/debug 1 then php bin/magento cache:flush. These debug headers are useful during setup but should be disabled in production (set to 0) to avoid leaking information.

If you’re getting MISS on every request, common causes are:

  • Magento is setting a Set-Cookie header on every response (logged-in users, or a misconfigured module).
  • Varnish backend is marked as sick — check docker exec magento-varnish varnishadm backend.list.
  • Varnish VCL has an issue — check docker logs magento-varnish.

Testing Cache Purging

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.

Sanity Check — Step 9

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

  1. Visit a CMS page (e.g., the homepage) in your browser — this warms the Varnish cache.
  2. 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.
  3. In the Magento admin, edit that CMS page (change a word, anything) and save it.
  4. Check again with the same curl command — should now show MISS, meaning Magento automatically told Varnish to purge that page.
  5. 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_hosts is set correctly in app/etc/env.php and 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.

Step 10 — Configuring Nginx to Proxy HTTPS to Varnish

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

SSL Certificate Strategy

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.

Creating a Temporary Self-Signed Certificate

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.

Nginx SSL Server Block

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.
  • Buffer settings â€” Magento pages can be large. The increased buffer sizes prevent Nginx from writing responses to disk.
  • The port 80 server block simply redirects all HTTP traffic to HTTPS. In practice, Cloudflare will handle this redirection, but having it here is a good safety net.

Enable the Site

sudo ln -s /etc/nginx/sites-available/magento-ssl /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Confirming the Traffic Flow

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.

Sanity Check — Step 10

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

Step 11 — Cloudflare Configuration

This is the step where your store goes live. We’ll configure Cloudflare to handle DNS, SSL, and edge protection for your Magento store.

DNS Setup

If you haven’t already, add your domain to Cloudflare and point your DNS to the server.

In the Cloudflare dashboard:

  1. Go to DNS â†’ Records.
  2. Add (or edit) an A record:
    • Name: your subdomain or @ for the root domain (e.g., shop if your store is shop.example.com)
    • IPv4 address: your server’s public IP
    • Proxy status: Proxied (orange cloud) — this is important. It means traffic goes through Cloudflare rather than directly to your server.
    • TTL: Auto

The orange cloud (Proxied) means Cloudflare sits between visitors and your server, providing SSL, caching, DDoS protection, and WAF.

SSL Mode — Full (Strict)

This is critical. Go to SSL/TLS â†’ Overview and set the encryption mode to Full (strict).

What the modes mean:

  • Flexible â€” Cloudflare terminates SSL, then connects to your server over plain HTTP. Do NOT use this — it creates redirect loops with Magento’s HTTPS configuration.
  • Full â€” Cloudflare connects to your server over HTTPS but doesn’t verify the certificate. Works but is less secure.
  • Full (strict) â€” Cloudflare connects over HTTPS and verifies the origin certificate. This is what we want.

Origin Certificate Setup

Now we’ll replace the self-signed certificate with a Cloudflare Origin Certificate that Cloudflare trusts.

  1. In Cloudflare, go to SSL/TLS â†’ Origin Server.
  2. Click Create Certificate.
  3. Leave the defaults:
    • Private key type: RSA (2048)
    • Hostnames: your domain and wildcard (e.g., example.com and *.example.com)
    • Certificate validity: 15 years (the default)
  4. Click Create.
  5. Cloudflare will show you the Origin Certificate and Private Key. Copy both — you won’t be able to see the private key again.

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

Real IP Restoration

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:

  • Always Use HTTPS: On
  • Minimum TLS Version: TLS 1.2
  • Automatic HTTPS Rewrites: On

Security → Settings:

  • I’m Under Attack Mode: leave disabled unless you’re actively experiencing a DDoS attack. Cloudflare’s security is now automated by default and will challenge suspicious traffic automatically.
  • Browser Integrity Check: On

Speed → Content Optimization:

  • Early Hints: On — allows browsers to preload CSS and fonts before the full HTML arrives, which can improve perceived load time.
  • Rocket Loader: Off — this defers all JavaScript loading, which conflicts with Magento’s script bundling and can break checkout flows, payment gateways, and other interactive features. Leave it off.
  • Cloudflare Fonts: On if you’re using Google Fonts — reduces external requests and improves privacy.
  • Speed Brain: currently in beta, leave Off until it’s stable.

Caching → Configuration:

  • Browser Cache TTL: Respect Existing Headers — let Magento and Varnish control cache behavior rather than Cloudflare overriding it

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.

Sanity Check — Step 11

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:

  • Store loads over HTTPS with no certificate warnings
  • The padlock icon shows a valid certificate
  • The Magento homepage renders correctly
  • CSS and JS files load (the page looks styled, not broken)
  • You can access the admin panel at https://yourdomain.com/your_admin_path

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

Step 12 — Setting Up Magento Cron Correctly

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.

What Magento Cron Does

Magento has three cron groups that handle different types of tasks:

  • default â€” the main cron group. Handles cache management, email sending, sitemap generation, currency rate updates, catalog price rules, and dozens of other scheduled tasks.
  • index â€” handles scheduled reindexing. When indexers are set to “Update by Schedule” (which we did in Step 7), this group processes the index change log and keeps search, categories, and prices up to date.
  • consumers â€” starts and manages RabbitMQ queue consumers. These process async messages for inventory updates, bulk operations, and other queued tasks.

Installing Cron Under www-data

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)

Verifying Cron Execution

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.

Common Cron Mistakes

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)

Sanity Check — Step 12

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

Step 13 — Performance Validation Checklist

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.

Confirming Varnish Cache Hits

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

  • Pages with customer-specific content (logged-in users are never cached — that’s correct behavior).
  • Modules that set cookies on every request.
  • Low TTL settings in the VCL.

Checking Redis Usage

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

Checking OpenSearch Health

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

Confirming RabbitMQ Queues

<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 Verification

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.

Overall Server Resources

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

Quick Response Time Test

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

Sanity Check — Step 13

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.

Step 14 — Backup and Disaster Recovery Strategy

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.

Database Backups

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 Volume Backups

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.yml is in ~/docker, the volumes will be named docker_mariadb_data, etc. Check with docker volume ls and 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.

Magento File Backup

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.

Backup Storage Estimates

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 typePer backup (compressed)Copies keptTotal disk
Database~100 MB14 daily + 3 monthly~1.7 GB
Magento files (without media)~50-200 MB14 daily + 3 monthly~1-3.4 GB
Magento media (pub/media)~3-6 GB4 weekly~12-24 GB
Docker volumes~3-5 GB4 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/media directory 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)

Server Snapshots

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:

  • They’re typically taken once daily, so you could lose up to 24 hours of data.
  • Restoring a snapshot replaces the entire server — you can’t restore just the database.

That’s why we have the granular backups above — they let you restore specific components without rolling back everything.

Offsite Backup Strategy

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.

Restoring from Backup

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)

Sanity Check — Step 14

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

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.

Log Rotation

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)

Uptime Monitoring

You need to know when your store goes down before your customers tell you. There are several free and paid options:

  • UptimeRobot (free tier available) — monitors your URL and sends alerts via email, SMS, or Slack.
  • Hetrixtools (free tier available) — similar to UptimeRobot with additional server monitoring.
  • Cloudflare Notifications â€” Cloudflare can alert you on origin errors. Check Notifications in your Cloudflare dashboard.

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)

Disk and Memory Monitoring

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)

Deploy Script

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:

  • After running composer require to add or update extensions
  • After pulling code changes from version control
  • After updating Magento to a new patch version
  • Anytime you see instructions saying “run setup:upgrade and deploy static content”

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

Disable Varnish Debug Headers

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 using varnishlog.

Sanity Check — Step 15

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

Common Magento VPS Setup Mistakes (Troubleshooting)

These are the issues that come up most frequently when setting up Magento on a VPS. If something isn’t working, check here first.

Using “localhost” in Varnish VCL Inside Docker

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.

Varnish Backend Marked as “Sick”

If docker exec magento-varnish varnishadm backend.list shows sick, the health probe is failing. Common causes:

  • UFW blocking Docker-to-host traffic. Docker containers use the 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
  • Health probe missing Host header. The VCL probe must include a .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.

Missing X-Forwarded-Proto / HTTPS Redirect Loop

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;

Running Cron as the Wrong User

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)

Not Increasing vm.max_map_count

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)

OPcache Not Reloaded After Deployment

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 Ports Exposed to the Internet

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.

Magento cache:flush Not Purging Varnish

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)

Two-Factor Authentication Lockout

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.

Dollar Signs in Docker Compose Passwords

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.

Final Production Checklist

Before considering your store truly production-ready, run through this checklist:

Application:

  • Magento is in production mode (php bin/magento deploy:mode:show)
  • 2FA is re-enabled (or plan in place to enable before accepting real traffic)
  • Admin URL is randomized (not /admin or /backend)
  • Default admin password has been changed to something strong
  • Email/SMTP is configured and tested

Caching:

  • Redis is configured for default cache, page cache, and sessions
  • Varnish is returning HITs for anonymous pages
  • Varnish purge works from Magento admin (edit a page → cache invalidates)
  • http_cache_hosts is set in env.php
  • OPcache is enabled with validate_timestamps=0

Search:

  • OpenSearch cluster health is green or yellow
  • Indexers are set to “Update by Schedule”
  • All indexers show “Ready” status

Message Queues:

  • RabbitMQ is connected and healthy
  • Queue consumers are running via cron

SSL and CDN:

  • Cloudflare SSL mode is Full (strict)
  • Origin certificate installed (not self-signed)
  • Real IP restoration configured in Nginx
  • Varnish debug headers disabled

Cron:

  • Cron is running as www-data
  • All three groups configured (default, index, consumers)
  • cron_schedule table shows recent successful jobs

Server:

  • UFW firewall active (only ports 22, 80, 443 open)
  • Docker ports bound to 127.0.0.1
  • Fail2Ban running
  • SSH root login disabled
  • Log rotation configured

Backups:

  • Database backup script tested and scheduled
  • File backup script tested and scheduled
  • Docker volume backup script tested and scheduled
  • VPS provider daily snapshots enabled
  • Offsite backup strategy planned

Monitoring:

  • Health check script running via cron
  • External uptime monitoring configured (UptimeRobot or similar)
  • Disk space monitored

Deployment:

  • Deploy script (bin/deploy) tested and working
  • Team knows the deployment process

Congratulations — 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.

Posted by
Matt