MODX Revolution Multi-Site Architecture: Contexts as Tenancy Primitives
MODX Revolution uses Contexts as its multi-tenancy primitive—one installation, multiple isolated sites. This architecture note covers the minimal viable design, trust boundaries, operational verification, failure modes, and the triggers that force a move to separate installations.
23 Feb 2026, 12:59 UTC

The Problem: One Codebase, Multiple Distinct Sites
You need to serve example.com, shop.example.com, and api.example.com from a single MODX installation. Each site has its own domain, resource tree, configuration, and permission model. Spinning up three separate MODX installs multiplies maintenance, upgrades, and infrastructure cost. MODX Revolution solves this with Contexts—a built-in multi-tenancy primitive that isolates configuration and resource trees while sharing the core codebase and database.
Takeaway: Contexts give you logical isolation (separate site_url, site_start, ACLs, resource roots) without physical separation. They are not security boundaries by default; treat them as organizational boundaries unless you harden ACLs and session handling.
Requirements That Lead to Contexts
- Multiple domains or subdomains pointing to one document root
- Distinct resource trees (different home pages, templates, content structures)
- Per-site configuration (error pages, custom settings, URL schemes)
- Shared user base with context-scoped permissions
- Single deployment pipeline, single database backup
If you need hard data sovereignty (GDPR, client isolation), divergent PHP versions, or independent scaling, contexts are the wrong tool—use separate installations.
Smallest Suitable Design: Two Contexts, One Database
A minimal multi-site setup creates one additional context beyond the default web context. Example: a marketing site (web) and a headless API (api).
Step 1: Create the API Context
In the Manager: System → Contexts → Create New. Key fields:
| Field | Value | Purpose |
|---|---|---|
key | api | Internal identifier; used in context_key on resources |
name | API | Manager display name |
description | Headless JSON endpoints | Documentation |
site_url | https://api.example.com/ | Must match incoming Host header; trailing slash required |
site_start | 100 | Resource ID of the API entry point (create after) |
error_page | 101 | Resource ID for 404/500 JSON responses |
Save. MODX writes a row to modx_context and creates core/cache/api/.
Step 2: Add Context Settings
Right-click the api context → Update Context Settings. Add overrides:
# Example settings for API context
api_version = v1
json_errors = 1
cors_enabled = 1
cors_origin = https://app.example.com
These live in modx_context_setting keyed by context_key. Access in templates/plugins via [[++api_version]] or $modx->getOption('api_version').
Step 3: Create API Resources
In the resource tree, switch context dropdown to API. Create a container resource (ID 100) with template Blank or a custom API template. Children become endpoints: /v1/products, /v1/users. Set content_type to json on each.
Step 4: Verify Routing
Run from a host that can reach the server (replace localhost with your server IP):
# Test web context (default)
curl -H 'Host: example.com' http://localhost/
# Expect: HTML of web home page
# Test api context
curl -H 'Host: api.example.com' http://localhost/
# Expect: JSON from resource 100
# Verify context setting resolution
curl -H 'Host: api.example.com' http://localhost/ -s | grep -o '"version":"[^"]*"'
# Expect: "version":"v1" if template outputs [[++api_version]]
Where to run: Any machine with network access to the MODX web root. Permissions: None beyond HTTP access. Risk: None—read-only verification.
Trust and Data Boundaries
What Contexts Share
- Single MySQL connection pool (same
xpdoinstance) - Same PHP-FPM process / Apache thread
- Core tables:
modx_users,modx_session,modx_transport_packages - File system:
core/,assets/,connectors/ - Cache backend (Redis, Memcached, or file) unless partitioned
What Contexts Isolate
- Resource tree root (
context_keyonmodx_site_content) - Configuration values (
modx_context_setting) - ACL policies: Context Access, Resource Group Access per context
- Cache directories:
core/cache/web/vscore/cache/api/
ACL Hardening Checklist
- Create a Context Access ACL for each context: Security → Access Controls → Context Access → New. Principal: User Group
API Editors. Target Context:api. Policy:ResourceEditor(or custom). - Create Resource Group ACLs scoped to context: assign API resources to
API Resourcesgroup, grantAPI Editorsaccess only to that group. - Deny cross-context access explicitly: no
webcontext policy forAPI Editors. - Test: log in as an
API Editorsuser in Manager; verifywebresources are invisible.
Session Isolation
By default, session_cookie_path is / and session_cookie_domain is empty, so a session cookie set on example.com is sent to api.example.com. If the API context lacks a plugin to validate $modx->context->key === 'api' on OnHandleRequest, a user authenticated on the marketing site inherits that session on the API.
Fix: add a plugin on OnHandleRequest (priority 0):
if ($modx->context->key === 'api' && !$modx->user->hasSessionContext('api')) {
$modx->user->removeSessionContext('web');
$modx->sendUnauthorizedPage();
}
Or configure session_cookie_domain = .example.com and session_cookie_path = / in each context's settings, then use a plugin to invalidate cross-context sessions.
Operational Checks
Cache Partition Verification
After requests to both hosts, inspect cache directories:
ls -la core/cache/web/ core/cache/api/
# Both should contain config.cache.php, context_settings.cache.php, etc.
# File timestamps should update per-context on deploy
MODX 2.8+ prefixes cache keys with context automatically. On older versions, ensure plugins use $modx->cacheManager->get('key', ['context' => 'api']).
Configuration Drift Audit
SELECT context_key, `key`, value
FROM modx_context_setting
WHERE context_key IN ('web','api')
ORDER BY context_key, `key`;
Compare critical settings: site_url, site_start, error_page, publish_default, friendly_urls, use_alias_path.
Plugin Execution Order Debugging
Context loading happens in OnHandleRequest. A plugin that forces $modx->switchContext('web') before the router runs breaks multi-site. List plugins on that event:
SELECT p.name, p.priority, pe.event
FROM modx_plugins p
JOIN modx_plugin_events pe ON p.id = pe.pluginid
WHERE pe.event = 'OnHandleRequest'
ORDER BY pe.priority;
Ensure no plugin with priority lower than 0 (runs early) calls switchContext unconditionally.
Failure Modes
| Symptom | Root Cause | Fix |
|---|---|---|
Infinite redirect loop on api.example.com | site_url missing trailing slash or mismatched scheme (http vs https) | Set site_url = https://api.example.com/ exactly; clear cache |
| API returns marketing site HTML | Router matched web context first; site_url not unique enough | Ensure distinct hostnames; avoid overlapping subdirectory paths |
| Cache key collision leaks data | Pre-2.8 custom snippets using $modx->cacheManager->set('key', $data) without context | Upgrade or prefix keys manually: 'api_' . $key |
| DB connection exhaustion kills all sites | Single pool; one context's heavy query starves others | Read replicas per context; or separate installs for high-traffic contexts |
| Package install breaks API context | Transport package assumes web context during install | Install packages while web context active; test after |
Conditions That Change the Design
Trigger: Hard Data Sovereignty Required
Client A and Client B cannot share a database (contractual, GDPR, HIPAA). Action: Split into separate MODX installations. Use a shared core via symlinks or Composer if code reuse is needed.
Trigger: One Context Consumes 80%+ of DB Connections
API context under load starves marketing site. Action: Move API to dedicated read replica with separate DB user, or spin up a second MODX install pointing to replica. Keep contexts only if traffic is balanced.
Trigger: Divergent PHP/Extension Requirements
Marketing site needs imagick; API needs swoole for async. Action: Containerize per context (Docker/K8s) with separate PHP-FPM pools, or separate installs.
Trigger: Migration to Headless/API-First
Add a new api context with site_url = https://api.example.com/, site_start pointing to a REST router resource (e.g., pdoTools @FILE snippet or custom modRestController). Keep web for editorial workflow. This is the lowest-risk path—no data migration, just new context.
Upgrade Considerations
MODX 2.x → 3.x removes ExtJS Manager. Context-specific manager customizations (custom grids, trees, dashboards) break. Test each context's front-end and manager workflows on staging:
- Clone production DB and files to staging.
- Run
setup/in-place upgrade. - For each context: hit
site_url, verify home page, error page, 3 random resources. - Log into Manager as context-scoped user; verify resource tree, editing, publishing.
- Check
modx_context_settingfor deprecated keys (e.g.,manager_theme).
Verification Checklist for New Multi-Site Deploy
- Two contexts created with distinct
site_url(including trailing slash) site_startanderror_pagepoint to existing resource IDs in each contextcurl -H 'Host: ...'returns correct context's home page[[++site_url]]and[[*id]]resolve per context in templates- Cache directories
core/cache/context_key/exist and are writable by PHP user - Context Access ACLs grant least privilege; cross-context access denied
- Resource Group ACLs align with context boundaries
- Session plugin invalidates cross-context sessions (if shared domain)
modx_context_settingaudited for drift- Load test with
wrk -t4 -c100 -d30s -H 'Host: api.example.com' http://localhost/shows no DB connection errors
Limitations
- Contexts share a single point of failure: database, PHP process, filesystem.
- No native horizontal scaling per context; all contexts scale together.
- Manager UI shows all contexts to users with
contextspermission; UI clutter grows with context count. - Package ecosystem assumes
webcontext; third-party extras may misbehave.
When these limitations become blockers, the architecture shifts from "contexts within one install" to "multiple installs with shared core via Composer." That decision is driven by operational pain, not theoretical purity.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.