**Tags:** CMS · PHP

# WordPress

Production-grade [WordPress](https://wordpress.org) on [Zerops](https://zerops.io) — Composer-managed core, an isolated web root, a Redis object cache, S3-backed media, and real cron. A 12-factor take on WordPress, wired natively into the platform.

### Available Environments

- [AI Agent](https://app.zerops.io/recipes/wordpress.md?environment=ai-agent)
- [Remote (CDE)](https://app.zerops.io/recipes/wordpress.md?environment=remote-cde)
- [Local](https://app.zerops.io/recipes/wordpress.md?environment=local)
- [Stage](https://app.zerops.io/recipes/wordpress.md?environment=stage)
- **Small Production** ← current
- [Highly-available Production](https://app.zerops.io/recipes/wordpress.md?environment=highly-available-production)

### Services in this Environment

**Services:**

- **core** (core:single@2)
  - Containers: 1 × Shared Core, 0.00 GB RAM, 0 GB Disk
- **app** (ubuntu/php-nginx@8.4)
  - Containers: 1 × Shared Core, 0.56 GB RAM, 1 GB Disk
  - Repository: [zerops-recipe-apps/wordpress-app](https://github.com/zerops-recipe-apps/wordpress-app)
- **db** (mariadb:single@10.6) :3306
  - Containers: 1 × Shared Core, 0.38 GB RAM, 1 GB Disk
- **cache** (valkey:single@7.2) :6379, :6380
  - Containers: 1 × Shared Core, 0.56 GB RAM, 1 GB Disk
- **storage** (object-storage)
  - Containers: 1 × Shared Core, 0.00 GB RAM, 0 GB Disk

**Total Resources:** 5 containers, 1.50 GB RAM, 3 GB Disk

### One-Click Deploy (Import YAML)

Use this YAML with `zcli project import` to deploy this environment:

```yaml
# zeropsPreprocessor=on

# Small Production — single-node managed stores; the app on shared CPU, scaling
# 1 -> 2 containers behind a readiness-gated rolling deploy. The cheapest way to run
# WordPress for real; not redundant. Disable subdomain access once your own domain
# is attached.

project:
  name: wordpress
  corePackage: LIGHT
  tags:
    - wordpress
    - cms
    - php

services:
  # ---- app (production build; shared CPU, autoscaling 1 -> 2) ----
  - hostname: app
    type: php-nginx@8.4
    zeropsSetup: prod
    buildFromGit: https://github.com/zerops-recipe-apps/wordpress-app
    enableSubdomainAccess: true
    minContainers: 1
    maxContainers: 2
    verticalAutoscaling:
      minRam: 0.5
    envSecrets:
      WORDPRESS_TITLE: WordPress on Zerops
      WORDPRESS_ADMIN_USER: zadmin
      WORDPRESS_ADMIN_EMAIL: admin@example.com   # change after deploy (Settings → General)
      WORDPRESS_ADMIN_PASSWORD: <@generateRandomString(<20>)>
      # Unique auth keys & salts
      WORDPRESS_AUTH_KEY:         <@generateRandomString(<64>)>
      WORDPRESS_SECURE_AUTH_KEY:  <@generateRandomString(<64>)>
      WORDPRESS_LOGGED_IN_KEY:    <@generateRandomString(<64>)>
      WORDPRESS_NONCE_KEY:        <@generateRandomString(<64>)>
      WORDPRESS_AUTH_SALT:        <@generateRandomString(<64>)>
      WORDPRESS_SECURE_AUTH_SALT: <@generateRandomString(<64>)>
      WORDPRESS_LOGGED_IN_SALT:   <@generateRandomString(<64>)>
      WORDPRESS_NONCE_SALT:       <@generateRandomString(<64>)>

  # ---- managed stores (single-node; enable backups on db in the GUI) ----
  - hostname: db
    type: mariadb:single@10.6
    priority: 10
  - hostname: cache
    type: valkey:single@7.2
    priority: 10
  - hostname: storage
    type: object-storage
    priority: 10
    objectStorageSize: 10
    objectStoragePolicy: public-objects-read

```

---

## Next Steps

After deploying one of the environments and getting to know Zerops, you have two paths to choose from:

1. **Template Flow** — Clone our GitHub repositories and use the whole recipe as a template
2. **Integrate Flow** — If you already have an existing application on a similar stack, integrate the recipe setup with your application

Select a flow: [Template Flow](https://app.zerops.io/recipes/wordpress.md?environment=small-production&guideFlow=template) or [Integrate Flow](https://app.zerops.io/recipes/wordpress.md?environment=small-production&guideFlow=integrate)

Both flows are shown below:

## How to take over the Small Production environment

### 📦 Clone the template repositories

Fork or clone the following repositories to your local machine or GitHub account:

- [zerops-recipe-apps/wordpress-app](https://github.com/zerops-recipe-apps/wordpress-app)

### 1. Find your service name

Many commands and configurations need the exact name of your service. You can find it in the Zerops Dashboard.

- Open your project in the Zerops Dashboard.
- In the project overview, find the service you want to manage.
- Use this exact name whenever a command or pipeline configuration asks for `<service-name>`.

<img src="https://storage-prg1.zerops.io/4gfos-storage/copy1_cd2a6044c8.jpg" style="display: block; margin: 0 auto;" alt="Zerops GUI: Locating the Service Name" width="500" />

### 2. Configure deployment pipeline

Go to Service Settings > Pipelines & CI/CD Settings in the Zerops Dashboard and connect your repository.

For production, use a trigger on new tags. This keeps deployments intentional and tied to a specific version. You can also add a regex filter, such as `^v[0-9]+\.[0-9]+\.[0-9]+$`, if you want to allow only semantic version tags.

<img src="https://storage-prg1.zerops.io/4gfos-storage/triggerborder_b865860a89.jpg" style="display: block; margin: 0 auto;" alt="Zerops GUI: Triggers" width="500" />

Alternatively, add `zcli push` to your existing CI/CD pipeline if you want full control over when deployments happen.

Learn more about pipeline triggers: https://docs.zerops.io/features/pipeline

### 3. Deploy to production

Create and push a new Git tag to deploy a specific version of your app:

```bash
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0
```

> [!TIP]
> Open the pipeline detail in the Zerops Dashboard to check the build progress and verify that all steps finish successfully.

### 4. Configure autoscaling

Review the autoscaling settings for your runtime services and databases in Service Settings > Automatic Scaling Configuration in the Zerops Dashboard.

<img src="https://storage-prg1.zerops.io/4gfos-storage/scaling_ac0880aef5.png" style="display: block; margin: 0 auto;" alt="Zerops GUI: Autoscaling configuration" width="500" />

The most important settings are:

```yaml
verticalAutoscaling:
  minRam: 1
  minFreeRamGB: 0.5
  minFreeRamPercent: 20
```

> [!CAUTION]
> Pay attention to `minFreeRamGB`. This value tells Zerops when to scale RAM vertically. Adjust it based on your app’s real memory needs. RAM scales up immediately, while CPU scales after two consecutive measurements below the threshold.

> [!TIP]
> Run a quick stress test with a tool like hey before real users arrive. This helps you see how your app behaves under load and tune the autoscaling settings.

### 5. Set up your domain

To send real traffic to your app, configure public HTTP access in Service Settings > Public Access & Internal Ports in the Zerops Dashboard.

Add your custom domain and point your DNS records to the Zerops IPs shown in the dashboard:

<img src="https://storage-prg1.zerops.io/4gfos-storage/subdomain_8cafd801e8.jpg" style="display: block; margin: 0 auto;" alt="Zerops GUI: Public access and custom domain" width="500" />

```text
Type   Name          Content          TTL
A      example.com   <zerops-ipv4>    Auto
AAAA   example.com   <project-ipv6>   Auto
```

For wildcard domains, add a CNAME record for SSL validation.

Check the public access documentation: https://docs.zerops.io/features/access

> [!TIP]
> When changing DNS records for production, start with a low TTL value. Make sure SSL certificates are active before you disable the fallback Zerops subdomain.

Once everything works, you can disable the Zerops subdomain so all traffic goes through your custom domain.

---

### 🎉 You are good to go!

Your application is live in production and the core setup is complete.

The following sections are optional. They cover extra production features such as log forwarding, backups, and diagnostic access. You can stop here and come back later when you need them.

---

### 6. Set up log forwarding (Optional)

To send logs to an external service, go to Project Settings > Log Forwarding & Logs Overview in the Zerops Dashboard.

You can forward logs to services like Better Stack, Papertrail, or your own self-hosted solution.

Learn more about log forwarding: https://docs.zerops.io/references/logging

### 7. Configure database backups (Optional)

Manage automated encrypted backups in Service Settings > Backups in the Zerops Dashboard.

By default, backups run daily between 00:00 and 01:00 UTC.

Before a major deployment, create a manual protected backup:

```bash
zcli backup create <db-service> --tags pre-deploy,protected
```

Read the backup documentation for more options: https://docs.zerops.io/features/backup

### 8. Set up diagnostic access (Optional)

Use zCLI and VPN access when you need to inspect or maintain services directly.

For runtime services:

```bash
zcli vpn up
ssh <service-name>.zerops
```

For databases, connect through the VPN to reach the project’s private network, or set up secure direct IP access for your database admin tools.

Check the VPN documentation: https://docs.zerops.io/references/cli/commands#vpn-up

## How to integrate app with Zerops

### Add `zerops.yaml`

Add the following `zerops.yaml` file to the root of your repository:

```yaml
zerops:
  # ==========================================================================
  #  base — shared runtime config, inherited by both dev and prod (extends)
  # ==========================================================================
  - setup: base
    run:
      base: php-nginx@8.4
      os: alpine
      siteConfigPath: site.conf.tmpl
      envVariables:
        # Public URL — self-URL variable so WordPress builds correct absolute links.
        WORDPRESS_URL: ${zeropsSubdomain}
        WORDPRESS_TABLE_PREFIX: wp_
        # Database (MariaDB) — uses the platform-provisioned database, no manual CREATE.
        WORDPRESS_DB_HOST: ${db_hostname}
        WORDPRESS_DB_NAME: ${db_dbName}
        WORDPRESS_DB_USER: ${db_user}
        WORDPRESS_DB_PASSWORD: ${db_password}
        # Media -> object storage (S3/MinIO); path-style wired in mu-plugins/s3.php.
        WORDPRESS_STORAGE_URL: ${storage_apiUrl}
        WORDPRESS_STORAGE_BUCKET: ${storage_bucketName}
        WORDPRESS_STORAGE_KEY_ID: ${storage_accessKeyId}
        WORDPRESS_STORAGE_ACCESS_KEY: ${storage_secretAccessKey}
        # Object cache -> Valkey (AUTH mandatory on Zerops; phpredis ships in runtime).
        WORDPRESS_REDIS_HOST: ${cache_hostname}
        WORDPRESS_REDIS_PORT: ${cache_port}
        WORDPRESS_REDIS_PASSWORD: ${cache_password}
        # Email: unset by default (WordPress falls back to PHP mail()). Set
        # SMTP_HOST_OVERRIDE (+ port/auth) to route mail through a real SMTP relay.
        WORDPRESS_SMTP_HOST: ${SMTP_HOST_OVERRIDE:-}
        WORDPRESS_SMTP_PORT: ${SMTP_PORT_OVERRIDE:-587}
      initCommands:
        # First-ever boot: install WordPress + enable Redis object cache (once, ever).
        - sudo -E -u zerops -- zsc execOnce wpinit --retryUntilSuccessful -- bash ./utils/initialize.sh
        # Once per deployed version: DB schema upgrades, keep infra plugins + cache on.
        - sudo -E -u zerops -- zsc execOnce ${appVersionId} --retryUntilSuccessful -- bash ./utils/upgrade.sh
      healthCheck:
        httpGet:
          port: 80
          path: /wp/wp-includes/images/blank.gif
      # Real WordPress cron (WP pseudo-cron is disabled in wp-config.php).
      crontab:
        - command: cd /var/www && wp cron event run --due-now --quiet
          timing: "*/5 * * * *"
          allContainers: false

  # ==========================================================================
  #  prod — production build + hardened, timestamp-frozen OPcache
  # ==========================================================================
  - setup: prod
    extends: base
    build:
      base: php@8.4
      os: alpine
      envVariables:
        COMPOSER_ALLOW_SUPERUSER: 1
      addToRunPrepare:
        - utils/run-prepare.sh
      buildCommands:
        # composer install runs the post-install hook (composer.json "scripts")
        # that bakes the version-matched redis-cache object-cache.php drop-in into
        # the artifact — it ships with every container, no runtime enable step.
        - composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction
      deployFiles:
        - ./
      cache:
        - vendor
        - public/wp
    deploy:
      # Gate traffic on a static core asset (no DB dependency) → zero-downtime rollouts.
      readinessCheck:
        httpGet:
          port: 80
          path: /wp/wp-includes/images/blank.gif
    run:
      prepareCommands:
        # addToRunPrepare copies utils/ into /home/zerops/ (path preserved).
        - bash /home/zerops/utils/run-prepare.sh production
      envVariables:
        # WORDPRESS_ENV drives the debug defaults in wp-config.php (production =
        # all debug off). The WORDPRESS_DEBUG* flags are deliberately NOT set here:
        # a run.envVariables key can't be overridden by a service env, so baking
        # them would force a redeploy to toggle. Left unset, they default from this
        # value and an operator can flip e.g. WORDPRESS_DEBUG_LOG via a service env
        # + restart — no redeploy.
        WORDPRESS_ENV: production

  # ==========================================================================
  #  dev — full-source build for live editing over SSH (WP_DEBUG on)
  # ==========================================================================
  - setup: dev
    extends: base
    build:
      base: php@8.4
      os: alpine
      envVariables:
        COMPOSER_ALLOW_SUPERUSER: 1
      addToRunPrepare:
        - utils/run-prepare.sh
      buildCommands:
        # composer install runs the post-install hook that bakes in the object-cache.php drop-in.
        - composer install --optimize-autoloader --no-interaction
      deployFiles:
        - ./
      cache:
        - vendor
        - public/wp
    run:
      prepareCommands:
        - bash /home/zerops/utils/run-prepare.sh development
      envVariables:
        # development => wp-config.php defaults WP_DEBUG + WP_DEBUG_LOG on (display
        # stays off). Override any flag with a service env var + restart if needed.
        WORDPRESS_ENV: development

```

### 🎯 What's next?

**Deploy other environments** — Ready to scale? Deploy additional environments for different stages of your workflow:

- [AI Agent](https://app.zerops.io/recipes/wordpress.md?environment=ai-agent)
- [Remote (CDE)](https://app.zerops.io/recipes/wordpress.md?environment=remote-cde)
- [Local](https://app.zerops.io/recipes/wordpress.md?environment=local)
- [Stage](https://app.zerops.io/recipes/wordpress.md?environment=stage)
- [Highly-available Production](https://app.zerops.io/recipes/wordpress.md?environment=highly-available-production)

## Knowledge Base

### Platform Reference

- [Routing & Domains](https://docs.zerops.io/features/access)
- [Scaling](https://docs.zerops.io/features/scaling)
- [Environment Variables](https://docs.zerops.io/features/env-variables)
- [CLI (zcli)](https://docs.zerops.io/references/cli)

### Service Type Reference

**PHP+Nginx**

- [Build & Deploy](https://docs.zerops.io/bun/how-to/build-pipeline)
- [Customize Runtime](https://docs.zerops.io/bun/how-to/customize-runtime)

**MariaDB**

- [Connect](https://docs.zerops.io/mariadb/how-to/connect)
- [Backup & Restore](https://docs.zerops.io/mariadb/how-to/backup)
- [Manage](https://docs.zerops.io/mariadb/how-to/manage)
- [Scale](https://docs.zerops.io/mariadb/how-to/scale)

**Valkey**

- [Configuration & Access](https://docs.zerops.io/valkey/overview#service-configuration)

### General Reference

### Architecture
The `app` service (php-nginx) serves only `public/`; WordPress core is in `public/wp`, content in `public/wp-content`, and the real `wp-config.php` sits at the repo root — above the web root, so it can never be served. State is externalized: `db` (MariaDB) holds content, `storage` (object storage) holds media via `humanmade/s3-uploads`, and `cache` (Valkey) holds the persistent object cache via `redis-cache`. The `object-cache.php` drop-in is baked into the build artifact — copied from the version-matched `redis-cache` plugin during the build — so it ships inside every immutable container and connects to the managed Valkey on boot, with no runtime install step.

### Compared to Bedrock
If you already run [Bedrock](https://roots.io/bedrock/), the front half looks familiar — Composer-managed core, an isolated web root, configuration from the environment; those are sensible WordPress practices and this recipe applies them too. Where it goes further is everything below that line, wired to the platform rather than left for you to assemble: managed MariaDB, Valkey and object storage; a build-baked Redis object cache; S3 media; health- and readiness-gated zero-downtime deploys; real cron; and tuned OPcache — provisioned and running as one project, across six environments from an AI-agent workstation up to an HA cluster.

### Environment variables
- **Wired for you (in the app's `zerops.yaml`):** `WORDPRESS_DB_*` ← `db`, `WORDPRESS_STORAGE_*` ← `storage`, `WORDPRESS_REDIS_*` ← `cache`, `WORDPRESS_URL` ← the subdomain.
- **Generated secrets (in `import.yaml`):** the eight auth keys/salts and `WORDPRESS_ADMIN_PASSWORD`.
- **Yours to set:** `WORDPRESS_ADMIN_EMAIL`, and `SMTP_HOST_OVERRIDE`/`SMTP_PORT_OVERRIDE` to route mail through a real SMTP relay.
- **Booleans are coerced** — `WORDPRESS_DEBUG` etc. are parsed with `FILTER_VALIDATE_BOOLEAN`, so the string `"false"` is actually false (in plain WordPress it would be truthy and silently turn debug ON).

### Troubleshooting
- **Redis "connection timed out" right after deploy** — the drop-in degrades gracefully (`WP_REDIS_GRACEFUL`); the container serves uncached until Valkey warms up, then reconnects. Persistent failure → check the `cache` service and `WORDPRESS_REDIS_PASSWORD` (Valkey auth is mandatory on Zerops).
- **Media 404s** — media URLs point at the `storage` endpoint, not the app domain; confirm the bucket policy is `public-objects-read` and `mu-plugins/s3.php` set `use_path_style_endpoint`.
- **Uploads rejected at 50 MB** — that's the L7 balancer cap on the `*.zerops.app` subdomain, not PHP; use a custom domain for larger files.

---

## Related Recipes

- [Laravel minimal](https://app.zerops.io/recipes/laravel-minimal.md)
- [Laravel showcase](https://app.zerops.io/recipes/laravel-showcase.md)
- [PHP Hello world](https://app.zerops.io/recipes/php-hello-world.md)

