Multi-Site Options and Scaling

This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production.

This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages.

Joomla has no multisite feature in core. One installation serves one site, one database and one set of users; the Joomla Product Strategy lists a "Multi-/Subsite Concept" under "Defend and Expand" in the 7.x and beyond roadmap, with the caveat that all dates are tentative. Until it exists, running several sites means running several installations, and handling more traffic means running one installation on several web nodes. This page, for administrators and hosting engineers, covers both: the ways to operate several sites, how users can be shared between them, and what has to be shared, identical or unique per node when the Tallpine Hiking Club site (package pkg_tallpine, component com_tallpine, child template cassiopeia_tallpine) outgrows one server.

It was checked on 2026-10-30 against Joomla 6.2.x (5.x is in its security-only phase) on PHP 8.3 or later, MariaDB 11.8, Redis 8.10, nginx 1.30 and the PhpRedis extension 6.3.0. The behaviour of the session, cache and configuration code was read in the 6.2-dev branch of the source tree, because the Developer Manual does not document it yet. The settings screens are described in Global Configuration, the cache layers in Caching and Performance, and a single-container setup in Local Development with Docker; this page only places them in a multi-site or multi-node design.

What "multisite" means, and what Joomla offers

Three different requirements hide behind the word, and Joomla answers them differently.

Requirement Joomla answer today Consequence

Several independent sites (the club, a members' shop, a hut-booking site)

One installation per site

Each has its own code, configuration.php, database or table prefix, users, extensions and update schedule

One site in several languages or on several domains

The multilingual features: language content, associations and language switchers

One installation and one user base; see Multilingual Sites

One site on several servers for capacity or availability

A stateless web tier with shared services

Code and configuration.php identical on every node; sessions, cache, media and the scheduler placed deliberately

The roadmap is the reason none of this is a menu item. The strategy page names multisite among the features that "cannot be meaningfully realised in the extension ecosystem", so it is planned for core, but it appears only in the 7.x and beyond column (2027 and beyond), after the 6.x branch (2025 to 2027). Joomla 6.2 has no sites folder, no shared user table and no per-domain configuration. Anything that claims otherwise is an extension, a hosting-panel feature or a hack, and has to be judged on its own maintenance record.

Option 1: one installation per site

The supported pattern is the boring one: a complete, separate Joomla per site.

Aspect In practice

Code

One copy per site, from the same release package or the same container image tag

Database

One database per site, or one database with a different $dbprefix per site

Users and ACL

Independent: a Super User on one site is nobody on another

Updates

Per site: the core update, extension updates and the Automated Core Updates setting are independent

Backups

Per site: see Backups and Recovery

Failure isolation

A bad extension update or an attack on one site does not reach the others

Cost

More disk space and more updates to apply; mitigated by scripting

A shared database server, one database per site

The simplest layout gives each site its own database and database user on one server. Each configuration.php has its own $db, $user and $password, so a leaked credential exposes one site only. The Tallpine club runs three sites this way.

# One database and one least-privilege user per site (MariaDB).
# Passwords come from the environment, never from this file.
mariadb -u root -p <<SQL
CREATE DATABASE tallpine_club  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE tallpine_shop  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE tallpine_huts  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'tp_club'@'%' IDENTIFIED BY '${TP_CLUB_DB_PASSWORD:?set it first}';
CREATE USER 'tp_shop'@'%' IDENTIFIED BY '${TP_SHOP_DB_PASSWORD:?set it first}';
CREATE USER 'tp_huts'@'%' IDENTIFIED BY '${TP_HUTS_DB_PASSWORD:?set it first}';
GRANT ALL PRIVILEGES ON tallpine_club.* TO 'tp_club'@'%';
GRANT ALL PRIVILEGES ON tallpine_shop.* TO 'tp_shop'@'%';
GRANT ALL PRIVILEGES ON tallpine_huts.* TO 'tp_huts'@'%';
SQL

One database, several table prefixes

The installer asks for a table prefix, and several installations can share one database when their prefixes differ. The prefix must start with a letter and contain only letters, digits and underscores, it may be at most 15 characters, and on PostgreSQL it must be lower case. This suits hosting plans that grant a single database, but it couples the sites: a dump or restore of the database affects all of them, and one database user can read every site’s tables. Prefer one database per site whenever the host allows it. A prefix is not a way to share users: the user, ACL and session tables of each prefix are independent tables, and Joomla offers no supported way to point two installations at one #__users table.

One installation serving several domains

The User Guide describes the opposite approach in "Multiple Domains and Web Sites in a single Joomla! installation": add the domains as add-on domains in the hosting panel, build separate articles, menus and modules per site, give each site its own template through the template styles, and send each domain to its own start page with a rewrite or a small PHP redirect. The guide itself calls separate installations the best practice and presents this as an exception for special cases, and the page states that it was last updated in 2012. Its limits matter:

  • all domains share one user table, one ACL and one extension set, so a Super User on one is a Super User on all

  • every domain shares one cache, one search index (Smart Search) and one set of URLs, and the redirect methods can loop when combined with domain parking

  • Global Configuration (site name, mail, SEF, offline mode) is one set for the whole installation

  • an update or a bad extension affects every site at once

It is acceptable for two near-identical brand sites run by the same team, and a poor fit for sites with different owners, different security levels or different update windows.

Operating several installations without drowning

Because every site is its own installation, the work is repetition, and repetition is scripted. The console already covers the repeated tasks (core:update:check, update:extensions:check, config:get, config:set, cache:clean, database:export; see Command Line Interface). A loop over the site roots gives a fleet-wide check:

#!/usr/bin/env bash
# fleet-check.sh: report pending core and extension updates for every Tallpine site.
set -euo pipefail

SITES=(/srv/joomla/club /srv/joomla/shop /srv/joomla/huts)

for root in "${SITES[@]}"; do
  echo "== ${root}"
  php "${root}/cli/joomla.php" core:update:check
  php "${root}/cli/joomla.php" update:extensions:check
done

Keep the three installations on the same release line (see Versions, Release Cycle and Support), and use the same PHP image for all of them so that one set of server settings and one OPcache configuration apply.

Sharing users between sites

Because user tables are per installation, "one login for all Tallpine sites" is an integration problem. Three designs exist, in increasing order of coupling.

Provision accounts through the Web Services API

The Web Services API includes a users endpoint. A script, or the membership system the club already has, can create and update the same member on each site with an API token of a Super User or of a user allowed to manage users. This is a copy, not a share: each site keeps its own password hash, so a password change does not propagate unless the same script handles it. It needs no extension and no change to the login flow.

# TOKEN is an API token created in the user's profile, kept in the environment.
curl --silent --request POST "https://shop.tallpine.example/api/index.php/v1/users" \
  --header "X-Joomla-Token: ${TALLPINE_SHOP_API_TOKEN:?set it first}" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Ada Trailhead",
    "username": "ada.trailhead",
    "email": "ada@tallpine.example",
    "password": "CHANGE_ME_ON_FIRST_LOGIN",
    "password2": "CHANGE_ME_ON_FIRST_LOGIN",
    "groups": [2]
  }'

The group id 2 is the stock Registered group; the Trail Warden group of the club has a different id on every site, so read the ids from GET /api/index.php/v1/users/groups rather than hard-coding them. The API itself, tokens and the default plugin states are covered in Web Services API.

Authenticate against one external directory

Joomla’s authentication plugins decide who may log in. The core ships Joomla, Cookie and LDAP authentication plugins, and the LDAP plugin lets several installations check credentials against one directory server (a user record is created in each installation at first login). Single sign-on through OpenID Connect, SAML or an OAuth provider is available only through third-party extensions listed in the Joomla Extensions Directory, which Joomla 6.2 does not include; evaluate them like any extension (maintenance, update-server security flag, supported Joomla version) and see Security Hardening, MFA and Passkeys for how they interact with multi-factor authentication. In every case the groups and access levels are still local: map the directory group to a Joomla group on each site (see Users, Groups, Access Levels and ACL).

Setting a $cookie_domain of .tallpine.example does not make a login valid on the sibling sites. The cookie name is derived from each site’s $secret, and the session lives in each site’s own store, so a cookie from one installation is simply an unknown session for another. "Shared Sessions" in Global Configuration, a toggle in the Session fieldset, is unrelated: it shares one session between the front end and the administrator of the same installation. It is also unavailable when Force HTTPS is set to "Administrator Only", and changing it invalidates all existing sessions.

Scaling one site across several web nodes

A single well-tuned server with the caches of Caching and Performance carries most Joomla sites. A second web node is justified when one server can no longer serve the uncached work, or when a server failure must not take the site down. The first step is the same in either case: separate the web tier from state.

flowchart LR V[Visitors and editors] --> LB[Load balancer<br/>TLS and X-Forwarded-Proto] LB --> W1[Web node 1<br/>PHP and Joomla code] LB --> W2[Web node 2<br/>PHP and Joomla code] W1 --> DB[(MariaDB or PostgreSQL<br/>content, users, extensions)] W2 --> DB W1 --> RS[(Redis sessions<br/>no eviction)] W2 --> RS W1 --> RC[(Redis cache<br/>eviction allowed)] W2 --> RC W1 --> M[/Shared media<br/>images and files/] W2 --> M CRON[One scheduler runner] --> DB

The table below classifies everything a Joomla node holds, which is the working list for any scale-out.

State Where it lives by default In a multi-node design

Content, users, ACL, extensions list, update sites

The database

One shared database (made redundant separately)

Sessions

Database for new installs, filesystem as the form default

Database, Redis or Memcached, shared by all nodes

System cache

cache/ on the node (File handler)

Redis or Memcached, or a per-node cache accepting that clearing it on one node leaves the others untouched

Page cache plugin entries

The same cache store

Same as the system cache

Media: images/, files/ and other Filesystem - Local directories

The node’s disk

Shared volume or object storage via a media adapter extension

Thumbnails

media/cache/com_media/thumbs on the node

Shared with the media, or regenerated per node

configuration.php

The node’s disk, one file

Identical on every node

Extensions and templates

The node’s disk

Identical on every node: deployed, not installed through the browser

tmp/, administrator/logs/

The node’s disk

Per node is fine; ship the logs elsewhere

Scheduled tasks

Lazy scheduler in the visitor’s request

One runner

Identical configuration: the secret and everything derived from it

Every node must run the same configuration.php. The value that bites first is $secret, because Joomla derives names from it:

  • the session cookie name is an MD5 hash of the $secret and a per-application seed. Nodes with different secrets issue differently named cookies, so a visitor sent to a second node looks logged out, and an editor meets a login loop

  • the cache keys are prefixed with a hash of the $secret and contain a hash of the installation’s absolute path, so two nodes installed under different paths never read each other’s cache entries, and two sites that share one Redis with the same secret and path would read each other’s entries

  • ApplicationHelper::getHash() mixes the secret into hashes used for names and tokens throughout the code

The installer generates the secret on the first node; copy that file to the others (or, better, build it into the image), and never regenerate it per node. Install every node at the same path, which a container image guarantees. Give every distinct site its own secret and, on a shared Redis, its own database number or instance.

Sessions: database, Redis or Memcached

The Session Handler in the Session fieldset of the System tab chooses where session data lives. In Joomla 6.2 the list holds Database, Filesystem, Redis, Memcached (labelled "Experimental") and APC User Cache (APCu), and the handler is created from session_handler in configuration.php.

Handler Fit for several nodes Notes

Database

Yes, with no extra service

The default written by the installer. Every request reads and writes a row, so it adds load to the database that a busy site would rather spend on content

Filesystem

Only with a shared, locked file system

The form default; the Session Save Path field defaults to the PHP session.save_path or the system temporary directory when empty. Per-node files mean sticky sessions at the load balancer

Redis

Yes

Needs the PhpRedis extension; the settings are host, port, persistent connection, one password field and a database number

Memcached

Yes, but marked experimental in the UI

Needs the Memcached extension

APCu

No

Per-process shared memory of one node

The session Lifetime (minutes) setting accepts 1 to 16383 and defaults to 15. Redis uses it as the key time to live, the database handler as the age after which garbage collection deletes the row. Two more switches influence cost: "Track Session Metadata" writes the username, user id and application into the session table (the session table then records who is logged in where), and "Track Guest Session Metadata" does so for guests as well; turn the guest tracking off on a busy public site because every anonymous visitor otherwise creates a metadata row.

Redis sessions are the usual choice once a second node exists, with one rule from the Redis side: the session store must not evict keys under memory pressure, because an evicted session is a visitor who is silently logged out. Evicting a cache entry costs one recomputation, while evicting a session costs a login. Run two Redis instances, or at least two databases with a policy you can defend, instead of one shared store.

// Session part of configuration.php on every Tallpine node (values come from the environment, see below).
public $session_handler = 'redis';
public $session_redis_server_host = 'redis-sessions';
public $session_redis_server_port = 6379;
public $session_redis_persist = true;
public $session_redis_server_auth = '';   // set from the environment in the constructor
public $session_redis_server_db = 0;
public $lifetime = 60;                    // minutes of inactivity
public $shared_session = false;
public $session_metadata = true;
public $session_metadata_for_guest = false;

The Redis session handler throws a RuntimeException ("Redis is not supported on this system") when the extension is missing, which presents as a blank page or a 500 error on every request, the administrator included. Always confirm the extension in System, System Information, PHP Information before switching the handler, and keep a way to edit configuration.php by hand. Redis itself (persistence, replication, passwords, eviction policies) is covered in Redis architecture, keyspace and eviction, Redis replication, Sentinel and Cluster and Redis security; the Joomla form has only a single password field, so a Redis ACL user name cannot be supplied from the screen.

Session garbage collection for the database and filesystem handlers is a scheduled task ("Session GC") or the console commands session:gc and session:metadata:gc (the first takes --application with site as the default); Redis and Memcached expire keys by themselves.

Cache: Redis, Memcached or per node

System caching has three settings: Off, "ON - Conservative caching" and "ON - Progressive caching". The Cache Handler offered by 6.2 is File, Redis, Memcached (experimental) or APC User Cache, with Cache Time in minutes and the "Platform Specific Caching" switch that gives mobile browsers their own cache entries. The older User Guide page "Cache" still lists APC, Eaccelerator, Memcache and XCache; those handlers no longer exist, and the 6.2 source offers the four above. Older books, such as the 2023 Joomla! 4 Masterclass, also date from before these changes and do not discuss multi-node operation at all.

For several nodes the File handler has a practical drawback: Clear Cache in the administrator only empties the cache of the node that served the click. Redis gives one cache that every node reads and clears.

// Cache part of configuration.php.
public $caching = 1;                      // 1 conservative, 2 progressive
public $cache_handler = 'redis';
public $cachetime = 15;                   // minutes
public $cache_platformprefix = false;
public $redis_server_host = 'redis-cache';
public $redis_server_port = 6379;
public $redis_persist = true;
public $redis_server_auth = '';           // set from the environment in the constructor
public $redis_server_db = 0;

The Redis cache handler writes each entry with an expiry (SETEX), so a policy such as allkeys-lru or volatile-lru suits the cache instance. Pages for logged-in users are not cached by the Page Cache plugin, so the cache absorbs anonymous traffic and the database still carries editors, members and searches. An edge cache or CDN in front (see Caching and Performance) removes most of that anonymous load before it reaches any node.

Media: a shared directory or object storage

The Media Manager reads files through filesystem plugins. Joomla 6.2 ships one, "Filesystem - Local", whose directory list defaults to images and files; other adapters are not part of core. Uploaded files are therefore written to the disk of the node that handled the upload, and a second node returns a 404 for them. Two solutions exist:

  1. A shared writable volume for every directory the plugin exposes (NFS, a ReadWriteMany volume on Kubernetes, or a clustered file system). It needs no extension, but it carries the usual shared-file-system costs: latency, locking, and one more thing to keep available. On Kubernetes see Persistent volumes and storage classes and Volumes and ephemeral storage.

  2. Object storage through a third-party media adapter plugin from the Extensions Directory. The files live in a bucket and are served by the bucket or a CDN, and the nodes stay stateless.

Whichever is chosen, the same upload limits apply on every layer: the Media component’s Maximum Size (10 MB by default), PHP upload_max_filesize and post_max_size, and the load balancer’s body limit (client_max_body_size on nginx). The lowest of them decides, and the symptom is a vague upload error in the Media Manager. See Media Manager for the component settings.

The proxy, TLS and client addresses

Behind a load balancer that terminates TLS, Joomla must still generate https:// links. The URI code recognises an HTTPS request from $_SERVER['HTTPS'] or from an X-Forwarded-Proto header whose value is not http, so the proxy must send that header (or set HTTPS), and Force HTTPS in Global Configuration should be set to the entire site. Only the protocol is read from the header; Joomla does not use X-Forwarded-For for the client address on its own, so extensions that log or rate-limit by IP see the proxy address unless the web server rewrites it (for example the Apache mod_remoteip module or the nginx real_ip module restricted to the proxy’s network). The Enable Outbound Proxy setting in the Proxy fieldset is unrelated: it routes the site’s own outgoing requests, for example update checks, through a proxy. The full proxy configuration is in Web Server, Reverse Proxy and HTTPS.

One scheduler runner

Joomla’s scheduler can be started by the Lazy Scheduler (a script injected into visitor requests, enabled by default with a 300-second interval), by a web-cron URL, or by the CLI. With several nodes, the lazy scheduler makes every node a candidate runner. Disable it in the Scheduled Tasks options, and run the CLI from one place, such as a cron job on one host or a Kubernetes CronJob:

# Every minute from exactly one runner; the tasks themselves decide whether they are due.
* * * * * www-data php /var/www/html/cli/joomla.php scheduler:run --no-interaction

Task locking, timeouts and exit codes are explained in Scheduled Tasks. Do not start a second runner for resilience without reading that page first: two runners racing on the same task is exactly what the single-runner design avoids.

Updates and installations are a deployment concern

On several nodes, a change made through the administrator is made on the disk of one node. Installing an extension from the Extensions: Install screen, updating the core with the Joomla Update component, or an Automated Core Update triggered by the joomla.org service writes files on the node that served the request; the other nodes keep the old code, and the database, which is shared, now describes software they do not have.

Treat the code as an artifact:

  • build one image per release containing the core, pkg_tallpine and its dependencies, and roll nodes to it

  • switch Automated Core Updates off on these sites (php cli/joomla.php core:autoupdate:unregister, see Updating Joomla and Automated Core Updates), and take the update from the image build instead

  • run the database part of an update (the extension schema steps) once, from one node, after the new code is in place, and use a rolling strategy that tolerates the old code reading the new schema for a few minutes (see Deployment strategies)

  • restrict the Install screens to the Super User and prefer the extension:install console command on the build host

A reproducible scale-out: Compose with two nodes

The following Compose project runs the Tallpine site on two web nodes behind an nginx load balancer with MariaDB and two Redis services, as a local rehearsal of the table above. It is a demonstration, not a production design: both nodes mount one named volume as the web root, which gives them the same code, configuration.php and media, and stands in for the shared volume or the identical image of a real deployment. The container tooling is described in Compose for local development and Docker networking.

# Dockerfile: the official Joomla 6.2 image plus the PhpRedis extension.
FROM joomla:6.2-php8.4-apache
RUN pecl install redis-6.3.0 \
 && docker-php-ext-enable redis
# compose.yaml
services:
  lb:
    image: nginx:1.30-alpine
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      web2:
        condition: service_started

  web1: &web
    build: .
    environment:
      JOOMLA_DB_HOST: db
      JOOMLA_DB_NAME: tallpine
      JOOMLA_DB_USER: tallpine
      JOOMLA_DB_PASSWORD: ${TALLPINE_DB_PASSWORD:?set it in .env}
      JOOMLA_SITE_NAME: Tallpine Hiking Club
      JOOMLA_ADMIN_USER: Site Admin
      JOOMLA_ADMIN_USERNAME: tpadmin
      JOOMLA_ADMIN_PASSWORD: ${TALLPINE_ADMIN_PASSWORD:?set it in .env}
      JOOMLA_ADMIN_EMAIL: admin@tallpine.example
    volumes:
      - webroot:/var/www/html
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "curl -fs http://localhost/ > /dev/null || exit 1"]
      interval: 10s
      retries: 30

  web2:
    <<: *web
    environment:
      JOOMLA_DB_HOST: db
    depends_on:
      web1:
        condition: service_healthy

  db:
    image: mariadb:11.8
    environment:
      MARIADB_DATABASE: tallpine
      MARIADB_USER: tallpine
      MARIADB_PASSWORD: ${TALLPINE_DB_PASSWORD:?set it in .env}
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      retries: 10

  redis-sessions:
    image: redis:8.10-alpine
    command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"]
    volumes:
      - sessions:/data

  redis-cache:
    image: redis:8.10-alpine
    command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru", "--save", ""]

volumes:
  webroot:
  dbdata:
  sessions:
# nginx.conf: round-robin load balancing; TLS would be terminated here in production.
upstream tallpine {
    server web1:80;
    server web2:80;
}

server {
    listen 80;
    client_max_body_size 64m;

    location / {
        proxy_pass http://tallpine;
        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 $scheme;
    }
}

The web2 node starts only after web1 is healthy, because the image installs Joomla into an empty web root on first start and two nodes must not race to do it. With the stack up, switch both services to Redis once, from one node (the command rewrites configuration.php, which both nodes share here):

docker compose up -d --build

docker compose exec --user www-data web1 php cli/joomla.php config:set \
  session_handler=redis session_redis_server_host=redis-sessions \
  caching=1 cache_handler=redis redis_server_host=redis-cache

# Verify from the outside: log in at http://localhost:8080/administrator/, then stop one node.
docker compose stop web1
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/    # still answered by web2

If the administrator session survives the loss of the node that created it, sessions are shared. Opening http://localhost:8080/administrator/ after stopping web1 and finding the login still valid is the test that shows the Redis handler works; with the Filesystem handler on per-node storage the same test sends the user back to the login form.

Secrets in configuration.php

configuration.php is a PHP class named JConfig that the application instantiates at startup, so a constructor can assign values from the environment instead of storing database passwords, the secret and Redis passwords in a file that is copied into images and backups.

<?php
// configuration.php (abridged): the properties generated by the installer stay; the constructor overrides secrets.
class JConfig
{
    public $offline = false;
    public $sitename = 'Tallpine Hiking Club';
    public $dbtype = 'mysqli';
    public $host = 'db';
    public $user = 'tallpine';
    public $password = '';
    public $db = 'tallpine';
    public $dbprefix = 'tp_';
    public $secret = '';
    public $force_ssl = 2;
    public $log_path = '/var/www/html/administrator/logs';
    public $tmp_path = '/var/www/html/tmp';
    public $caching = 1;
    public $cache_handler = 'redis';
    public $session_handler = 'redis';
    // ... the remaining generated properties ...

    public function __construct()
    {
        $this->password = self::requireEnv('TALLPINE_DB_PASSWORD');
        $this->secret   = self::requireEnv('TALLPINE_SECRET');

        $this->session_redis_server_auth = getenv('TALLPINE_REDIS_SESSIONS_PASSWORD') ?: '';
        $this->redis_server_auth         = getenv('TALLPINE_REDIS_CACHE_PASSWORD') ?: '';
    }

    private static function requireEnv(string $name): string
    {
        $value = getenv($name);

        if ($value === false || $value === '') {
            // Fail at startup rather than run with an empty database password or a guessable secret.
            http_response_code(500);
            exit('Missing required environment variable ' . $name);
        }

        return $value;
    }
}

This pattern has one sharp edge. Saving Global Configuration, or running config:set, regenerates the whole file from the values currently loaded and writes it as a new JConfig class: the constructor disappears, and the secrets that were read from the environment are written to the file in clear text. In an environment-driven deployment, make the file read-only and owned by a different user than the PHP process, so that a save fails with "Could not write to the configuration file" instead of silently rewriting it (Joomla tries to make a file it owns writable before saving and returns it to 0444 afterwards), and change settings by redeploying. The file-level hardening of configuration.php and .htaccess is described in configuration.php and .htaccess.

Staging workflows

Joomla has no staging feature and no configuration management: there is no export of settings, menus or module assignments that can be promoted from one site to another. Everything except code lives in the database, so a staging workflow is "copy the production database and media down, change the code up". The Tallpine club runs three environments: local Compose, a staging site, and production.

Direction What travels

Code and extensions, staging to production

The image or the release package (core, pkg_tallpine, the child template), built once and deployed as a unit

Database, production to staging

A dump, scrubbed and then imported

Media, production to staging

A copy of images/ and files/

Content and settings, staging to production

Nothing automatic: re-apply by hand, with the API, or with an extension; or freeze content during the deploy

Because content cannot travel up, features are developed on staging against a copy of production data and released as code; configuration that must change (a new menu item, a module assignment) is then made once on production by an administrator, ideally scripted through the Web Services API, and recorded in the release notes. A schema change is shipped in the extension’s SQL update script so that the update step moves the production database forward (see Installer Scripts and SQL Updates).

A refresh of staging from production should always end with a scrub, so staging neither emails real members nor indexes itself:

#!/usr/bin/env bash
# refresh-staging.sh: copy production data to staging and neutralise it.
set -euo pipefail

: "${PROD_DB_PASSWORD:?set it first}" "${STAGING_DB_PASSWORD:?set it first}"

# 1. Dump production (a consistent snapshot, without locking the site for long).
MYSQL_PWD="${PROD_DB_PASSWORD}" mariadb-dump --single-transaction --quick \
  --host prod-db --user tp_club tallpine_club > /tmp/tallpine.sql

# 2. Load it into the staging database.
MYSQL_PWD="${STAGING_DB_PASSWORD}" mariadb --host staging-db --user tp_club tallpine_staging < /tmp/tallpine.sql

# 3. Copy the uploaded media.
rsync -a --delete prod:/srv/joomla/club/images/ /srv/joomla/staging/images/
rsync -a --delete prod:/srv/joomla/club/files/  /srv/joomla/staging/files/

# 4. Neutralise staging.
cd /srv/joomla/staging
php cli/joomla.php config:set offline=false robots=noindex,nofollow mailonline=false
php cli/joomla.php core:autoupdate:unregister
php cli/joomla.php cache:clean

Three details are easy to forget after a copy. The copied database still holds real member e-mail addresses and the Mail Templates, so keep outgoing mail on a catcher such as Mailpit (see Mail Templates and Email Settings) or the staging mailonline switch off. The Smart Search index, the scheduled tasks and the update sites also came along, so disable tasks that call outside services (such as the trail-closure sync of plg_task_tallpine_trailsync). And the Site Offline and robots settings are database-independent configuration values, so they must be set explicitly on each environment. The backup tooling and restore drills are in Backups and Recovery.

Scaling beyond the web tier

When Redis, a CDN and two web nodes are not enough, the database becomes the bottleneck, and the options are those of any LAMP application, not Joomla-specific: tune queries and indexes, give the database more memory, add a managed or replicated database, and (carefully) cut the work per request. The core configuration holds a single database host, so a replica is best used for backups and reporting rather than for page requests. MySQL to PostgreSQL migrations and downgrades are not supported by Joomla, so choose the engine for the long term.

On Kubernetes the same design maps to a Deployment of stateless web pods (see Autoscaling), a CronJob for scheduler:run, a ReadWriteMany volume or an object-storage adapter for media, and managed Redis and database services, with the configuration injected from Secrets as environment variables. The requirements and hosting constraints of each Joomla release are in Requirements and Hosting.

Common errors and pitfalls

  • Login loop after adding a node. The nodes have different $secret values, or sessions are on per-node storage. Compare the cookie names the two nodes issue and the session handler on each.

  • Images uploaded in the Media Manager 404 on some requests. The upload went to one node’s disk. Share the directories listed in the Filesystem - Local plugin, or use an object-storage adapter.

  • Clearing the cache seems to do nothing. The File handler clears one node only. Move the cache to Redis.

  • All sessions end at once. The session Redis instance evicted keys or was restarted without persistence, or Shared Sessions was toggled (which invalidates all sessions by design).

  • Blank page after choosing Redis. The PhpRedis extension is missing on a node, or the host name is unreachable. Fix configuration.php by hand.

  • Two sites share a Redis and show each other’s content. They have the same secret and the same installation path. Give each its own secret and Redis database number or instance.

  • An extension installed on one node is "missing" on another. Installations through the browser change one node. Deploy the same package to every node.

  • A saved Global Configuration wiped the environment-driven setup. Saving rewrites the whole file; keep it read-only and change settings by redeploying.

  • Scheduled tasks run twice or never. The lazy scheduler is still enabled on every node while a CLI runner also exists, or no runner exists in a read-only container.

  • Using one installation for unrelated sites to save effort. One compromised extension or user exposes all of them. Use separate installations unless the sites share an owner and an update window.

  • Relying on the 2012 multiple-domains note or old multisite tutorials. Joomla 6.2 has no multisite; those methods are workarounds and need the same care as an unsupported hack.

Version notes

  • The multisite concept is a 7.x roadmap theme (2027 and beyond) and is absent from Joomla 6.2.

  • The Session Handler list in 6.2 is Database, Filesystem, Redis, Memcached and APC User Cache, and the web installer writes the Database handler into new sites.

  • Automated Core Updates (5.4 and 6.0) are on by default for new installations and off for upgraded sites, which is why a fresh node image usually needs them switched off; core updates have been verified with TUF since 5.1.

  • Joomla 7.0 removes the auto-detection of $app and $db properties of plugins, which matters to any plugin you add to the image for caching or session logic; see Versions, Release Cycle and Support.

  • The Developer Manual has no page on scaling or sessions yet; the behaviour above comes from the 6.2 source tree and may change in a minor release, so re-read the cited files before depending on a detail.

Where older books and tutorials are outdated

Joomla! 4 Masterclass (2023) and Developing Extensions for Joomla! 5 (2023) concern a single site on a single server and do not describe scale-out. Both pre-date Joomla 5.1 and 6: they do not mention Automated Core Updates, whose effect on a multi-node deployment is described above, the Backward Compatibility 6 plugin, or the current platform. The User Guide’s cache-handler list (APC, Eaccelerator, Memcache, XCache) is out of date for 6.2, and the legacy wiki and older tutorials describe multisite through shared tables or extensions of Joomla 1.5 to 3.x that are neither supported nor tested on 6.x. For multisite and scaling, the Product Strategy, the User Guide’s system pages and the source tree are the reliable references.