Why Bedrock?
Stock WordPress puts everything — core, plugins, uploads,wp-config.php, and your application code — in one big bucket at the web root. That works on shared hosting but fights you on a PaaS, where you want a clear split between immutable build artifacts (deployed via Git) and writable persistent data (mounts). Additionally, the modern method of managing a web application’s codebase and its dependencies is Composer.
Bedrock fixes this by:
- Moving the WordPress core into
web/wp/so it can be managed entirely by Composer. - Putting plugins, themes, and uploads under
web/app/(the equivalent ofwp-content/). - Loading config from environment variables via
vlucas/phpdotenv, which Upsun fills in for you. - Generating salts and DB credentials at runtime instead of committing them.
templates_c, civi custom dirs) live on Upsun mounts.
Prerequisites
Before pushing anything, you’ll want:- An Upsun account and a project created in the console.
- The Upsun CLI installed locally.
- PHP 8.1+ and Composer 2.x for local development.
- Four CiviCRM environment variables generated and ready to set in the Upsun console:
CIVICRM_SITE_KEYCIVICRM_CRED_KEYSCIVICRM_SIGN_KEYSCIVICRM_DEPLOY_ID
Quick start
Use these commands to create a new Upsun project using a clone of my Skvare/upsun-wordpress-bedrock-civicrm-template:.env is only read when not on Upsun; in production, the .environment shell file (covered below) builds the same variables from Upsun’s relationship and route data. Commit Bedrock’s .env.example so collaborators have a starting point, but never commit .env itself.
The Upsun configuration
Everything Upsun needs to build and deploy the site live is a single file:.upsun/config.yaml. Let’s walk through it section by section.
Application stack
opcache and apcu for performance, redis if you choose to add a Redis service for caching, sodium for modern crypto, imagick for receipt/contact photo handling, mbstring for multi-byte string operations, and blackfire for production profiling when you need it.
This only matters on Upsun Fixed: if you don’t have a Blackfire subscription, drop that line, since listing an unlicensed extension fails the build. Upsun Flexible bundles a Blackfire license, so you can keep the extension as-is.
Database: one service, two schemas
I recommend installing CiviCRM in its own database. On Upsun you don’t need to provision two separate services — MariaDB supports multiple schemas in one instance, with separate endpoints per schema:max_allowed_packet: 64(MB) gives CiviCRM enough headroom for bulk imports and large mailings; the default is too small.default_charset: utf8mb4and the matching collation are non-negotiable for international data and emoji.innodb_adaptive_hash_index: 0is a CiviCRM-specific tuning hint — the AHI tends to hurt more than help on CiviCRM’s query patterns, especially under contention.- Two endpoints (
mysqlandcivicrm) let you give each connection a different default schema while still allowing CiviCRM to reach into the WordPress (main) schema for user lookups via the UF (User Framework) tables.
Mounts: where writable data lives
Bedrock plus CiviCRM has four directories that absolutely need to be writable at runtime, and they need to not be in your Git repo:web/app/uploads holds both WordPress media library files and CiviCRM’s persist, custom, and upload directories — the latter three are subdirectories created by CiviCRM on first run. Putting them all under a single mount keeps backups simple and avoids cross-mount permission surprises.
web/app/wp-content/cache is a separate mount because page cache plugins churn it constantly; isolating it from media uploads keeps backup snapshots small.
/tmp uses the tmp source rather than storage, so it doesn’t count against your persistent disk and is safe to wipe between containers.
Note what’s not mounted: web/app/civicrm-ext/, where Composer-managed extensions get installed. That directory is part of the build artifact, so extensions are versioned in composer.json and not editable through the CiviCRM UI in production. This is the right tradeoff for a PaaS — predictable deploys, no drift.
Build hook
composer install for you. The added CLI install gives you the Upsun CLI inside the container, which is occasionally useful for source operations and scheduled tasks that need to talk back to the Upsun API.
Deploy hook
wp cache flush clears any stale object cache between releases. wp core update-db runs WordPress’s own DB migrations on core upgrades — it’s idempotent and cheap when there’s nothing to do.
Web locations: serve statically, block PHP everywhere it shouldn’t run
This is the longest section of the config and the most security-critical. The high-level rule is: PHP only executes from places we explicitly trust. Everything in uploads, cache, and the various civi data dirs serves static assets but blocks*.php execution.
The root web location handles WordPress requests:
web/wp/, so passthru points there. The rules block hidden files, the Composer manifest, and the standard WordPress info pages that leak version data.
Uploads are explicitly hardened:
/app/civicrm-ext, /app/uploads/civicrm/persist, /app/uploads/civicrm/custom, and /app/uploads/civicrm/upload. Every path that accepts user-uploaded data has the same allowlist of safe extensions and an explicit denylist of every PHP-ish extension that could be exploited. If you add a new mount that takes uploads, copy this block.
The one exception that legitimately needs PHP execution under uploads is KCFinder, the file browser bundled with CiviCRM:
Routes and cache cookies
wordpress:http here. Mismatched names produce a deploy that succeeds but serves only 502s.
Second, the cookies allowlist is the difference between Upsun’s edge cache being useful and being a security concern. By naming only the cookies that actually distinguish users, anonymous visitors get cached responses while logged-in users (and members, in the case of simple_wp_membership_sec_) bypass the cache cleanly. CiviCRM contribution and event pages are mostly anonymous traffic; this configuration lets them be served from cache, which matters during big campaigns.
Crons
Two scheduled crons cover both WordPress and CiviCRM:wp cron handles WordPress core scheduled events. The civi-cron job runs CiviCRM scheduled jobs through the WP-CLI integration, executing as a dedicated cronuser account that you’ll create after the initial install (covered below).
For sites that send a lot of mail, you may want to split out CiviCRM mailing processing onto a faster cadence (e.g. every 5 minutes) by adding a third cron that runs wp --user=cronuser civicrm api job.process_mailing auth=0 — the same separation we use on the Drupal side.
Source operations: auto-update
upsun source-operation:run auto-update (or schedule it) and have Upsun open a merge request or branch with composer update results applied. For CiviCRM sites, this is how you keep up with security releases without doing a manual composer update push every time.
The Composer template
Thecomposer.json is where the WordPress + CiviCRM marriage actually happens. The interesting parts:
Repositories and packages
wpackagist.org provides Composer-flavored access to the WordPress.org plugin and theme directories. The CiviCRM repos point to specific upstream sources, including a Skvare fork of civicrm-extension-plugin.
The require block pins CiviCRM at 6.13.* and pulls in civicrm-asset-plugin, civicrm-extension-plugin, civicrm-core, civicrm-packages, and civicrm-wordpress together. It also pulls in skvare/wordpress-bedrock-civicrm-settings — a small package that ships a pre-configured civicrm.settings.php tailored to the Bedrock + Upsun layout. Doing this as a Composer package instead of an in-repo file makes upgrading the settings template across multiple sites a single version bump.
CiviCRM extensions via Composer
Out of the box, CiviCRM extensions are installed and updated through the CiviCRM admin UI — which on a read-only filesystem like Upsun is a non-starter. Thecivicrm/civicrm-extension-plugin (Skvare fork) lets you declare extensions in composer.json instead:
web/app/civicrm-ext/contrib, and made available to CiviCRM. Pinning the URL means deploys are reproducible — the same Git SHA always produces the same extension set.
To add an extension, find its .zip URL on civicrm.org, add it to this list, run composer update, and push.
Patches
civi-core and civi-wordpress patches make necessary changes for discovering CiviCRM Core in composer’s vendor directory, and relax assumptions about being able to write civicrm.settings.php at runtime — exactly the assumption a read-only PaaS filesystem violates. These will go away once the upstream PRs merge; in the meantime, cweagans/composer-patches applies them at install time.
Post-install scripts
civicrm.settings.php from the Skvare settings package into the location CiviCRM looks for it. The second line runs civicrm:publish, which copies CiviCRM’s static assets into the location declared earlier (web/app/plugins/civicrm/civicrm). Both run during build, so the artifact that ships to Upsun has settings and assets already in place.
The .environment shell file
Bedrock expects WordPress configuration to come from environment variables. On Upsun, you don’t write a literal.env file in production — instead, .environment runs at container start and exports the same names from Upsun’s built-in variables:
PLATFORM_ROUTES parse: rather than hard-coding WP_HOME per environment, we ask Upsun what the primary route for this application is and trim the trailing slash. Branch environments automatically get the right URL; production gets the production URL. Bedrock reads WP_HOME and WP_SITEURL from these exports through phpdotenv-style loading.
PLATFORM_PROJECT_ENTROPY is per-project secret entropy that Upsun guarantees is stable and unique. Concatenating it with each salt name gives you eight different, deterministic, project-scoped values — no need to commit secrets, no need to rotate them on every deploy.
First-time WordPress + CiviCRM install
Aftergit push upsun main succeeds, follow this order:
1. Run the WordPress installer
Open the project URL (runupsun url --primary to open it in your browser). You’ll see the standard WordPress 5-minute install. Set the site title, admin user, and admin password. The DB connection is already wired up via .environment, so you won’t see a database setup step.
2. Activate the CiviCRM plugin
Go to Plugins in the WordPress admin. CiviCRM is already installed as a Composer plugin underweb/app/plugins/civicrm/. Click Activate.
The plugin will detect the pre-shipped civicrm.settings.php and the configured civicrm database endpoint, run its install routine, and finish without prompting for DB credentials.
Visit /wp-admin/admin.php?page=CiviCRM to confirm CiviCRM loaded correctly.
3. Create the cron user
CiviCRM’s API runs as a WordPress user. Thecivi-cron job runs wp --user=cronuser ..., so create a user with that exact username:
- Navigate to Users → Add New.
- Username:
cronuser. - Role: Administrator.
*/30 * * * * starts succeeding silently. Tail the cron log via upsun log cron to confirm.
4. Activate any extensions you declared
Extensions you listed incomposer.json are installed (their files are on disk) but not enabled. Go to CiviCRM → Administer → Extensions and click Enable on each one. This is the only ongoing UI step CiviCRM extensions require — once enabled, the state lives in the database, so subsequent deploys keep them on.
Things to know once you’re running
- UI warnings: The CiviCRM admin UI will warn about extension installs being unavailable. That’s expected — you’re managing extensions through Composer, not the UI. Document this in your runbook so future developers don’t think it’s a bug.
- Mailings: If you do significant outbound email, add a dedicated mailing cron at
*/5 * * * *runningwp --user=cronuser civicrm api job.process_mailing auth=0and let the half-hourly cron handle everything else. Don’t try to send mailings synchronously from web requests. - Caching: CiviCRM defaults to a database-backed cache. For larger sites, add a Redis service to the project, expose it as a relationship, and set
CIVICRM_CACHING_TYPE=Redisas an Upsun variable. The PHP redis extension is already in the runtime list. - Backups: Upsun’s automatic snapshots cover both the database and persistent mounts, so the uploads mount and CiviCRM’s persist directory are included in restores. You don’t need a separate backup strategy unless you want offsite copies.
- Branch environments: Because
WP_HOMEandWP_SITEURLare derived fromPLATFORM_ROUTES, every branch environment gets the right URL automatically. CiviCRM’sCIVICRM_DEPLOY_IDis a single env var across environments, though — if you want per-environment deploy IDs to invalidate asset caches independently, set the variable per environment in the Upsun console rather than at the project level.
Wrapping up
The combination of Bedrock’s Composer-first layout and Upsun’s PaaS primitives gives CiviCRM a much better home than traditional shared hosting: predictable, reproducible deploys; sensible filesystem boundaries between code and data; and a database service that scales without you having to think about it. The template is open source and MIT-licensed. If you find a rough edge or want to add a configuration option (Redis, multisite, a particular extension), pull requests are welcome at github.com/Skvare/upsun-wordpress-bedrock-civicrm-template.Author experience and approach Mark Hanna is CTO of Skvare. He’s the author of the Drupal 11 + CiviCRM on Upsun guide and maintains several CiviCRM-adjacent open source modules including CiviCRM Entity, CiviCRM Form Builder Blocks, CiviCRM Drush, and CiviCRM Reroute Mail. If your organization needs help architecting a CiviCRM install — WordPress, Drupal, or Joomla — Skvare does this professionally.