SVPortal — Installation & Deployment Reference

This is the reference guide for deploying and running SVPortal on an OpenSimulator grid. It is a living document and will grow as more deployment experience is gathered.

How the documentation fits together

DocumentUse it for
QuickStart.mdThe minimum you must do by hand to reach the sign-in page: web server, TLS, config.php, database schema and grants. Start there.
Setup (in the portal)Almost everything else: grid name, ROBUST, RemoteAdmin, email, feature switches, file/folder paths, themes. See SVPortal-Setup.md for a tab-by-tab description.
Install.md (this file)Everything that has to happen outside the portal (OpenSim/ROBUST configuration, folders and permissions on disk, cron, web server hardening), plus reference notes and troubleshooting.

Order of play: follow QuickStart.md until you can sign in as an Administrator, then come back here for the sections you need. Where this document says “set X in Setup”, it means the Setup page in the portal (Administration → Setup, Administrators only) — not a file.


Contents

  1. Prerequisites
  2. HTTPS requirement
  3. Licensing
  4. New install or upgrade
  5. Securing config.php and the web server
  6. Database user and grants
  7. OpenSim and ROBUST configuration
  8. Configuring the portal through Setup
  9. Folders on disk
  10. Cron jobs
  11. Topology: one region per simulator
  12. Email
  13. TinyMCE (optional rich text editor)
  14. Monit (process supervision)
  15. Known OpenSim behaviour notes
  16. Troubleshooting
  17. MySQL-specific notes
  18. Database collation defaults
  19. Appendix A — Running on Apache
  20. Appendix B — Separating portal tables into their own database

Prerequisites

  • PHP 8.3 or 8.4 with the following extensions:
    • pdo_mysql — all database access (db.php and everywhere it’s included)
    • mbstring — multi-byte string handling (admin.php, destination_guide.php, messages.php, password_policy.php, region_broadcast.php); missing this produces Fatal error: Uncaught Error: Call to undefined function mb_substr() (or mb_strlen(), etc.) rather than a friendlier warning
    • curl — outbound HTTP to ROBUST, RemoteAdmin, hypergrid destinations, and jsonSimStats (helpers.php, hg_asset.php, hg_discovery.php, hg_profile.php, profile_image.php, region_image.php, region_stats.php, remoteadmin.php, robust_api.php)
    • imagick (php-imagick) — JPEG2000 → PNG map tile conversion, and resizing uploaded profile/region/event/link images (helpers.php, hg_profile_image.php, profile_image.php, region_image.php)
    • fileinfo — MIME type detection on uploaded files (helpers.php)
    • simplexml — parsing ROBUST/hypergrid XML responses (hg_asset.php, hg_discovery.php, messages.php, robust_api.php); missing this produces Fatal error: Uncaught Error: Call to undefined function simplexml_load_string()
    On Debian/Ubuntu, one line covers all of the above for PHP 8.4 (adjust the version suffix to match your installed PHP):

bash

  sudo apt install php8.4-mysql php8.4-mbstring php8.4-curl php8.4-imagick php8.4-fileinfo php8.4-xml

(php8.4-xml provides simplexml, dom, and a few other XML-related extensions bundled together on Debian/Ubuntu — there’s no separate php8.4-simplexml package.)

fileinfo, simplexml, ctype, json, and session all ship enabled by default on a normal distro PHP build and are rarely worth a second thought — but a minimal/hardened install, a manually-compiled PHP, or some slimmed-down container base images can omit any of them, and the resulting fatal errors point at the calling code, not the missing extension, so they’re easy to misdiagnose. Run php -m and check the extension you need appears in the list if anything on this page throws an “undefined function” fatal.

  • MariaDB (shared with your OpenSim/ROBUST database, or separate — see Appendix B). MySQL also works but expect some collation/charset differences from what’s documented here, which assumes MariaDB defaults; see MySQL-specific notes.
  • Nginx with PHP-FPM is assumed throughout. Apache is possible but is not a fully supported configuration — see Appendix A.
  • A working OpenSimulator grid with ROBUST running
  • PHPMailer is bundled in includes/phpmailer/ — no Composer needed
  • HTTPS — see below

HTTPS requirement

SVPortal requires HTTPS and will not function correctly over plain HTTP.

Session cookies are set with the Secure flag (see includes/auth.php, session_start_secure()), which instructs the browser to withhold the cookie on any non-HTTPS connection. Without HTTPS:

  • Login will appear to fail at the CSRF check, even with correct credentials
  • Sessions will not persist between page loads
  • Users will be redirected back to the login page in a loop

This is a deliberate security default, not a bug. A portal that handles account passwords should not run over an unencrypted connection.

Before installing, make sure you have:

  • A domain or subdomain pointed at your server
  • A valid TLS certificate (e.g. via Let’s Encrypt / certbot)
  • Your web server configured to serve the portal over HTTPS

If you’re testing on a local/LAN-only box with no public domain, a self-signed certificate is sufficient — the browser will show a trust warning you can accept, but the connection will still be HTTPS.

Modifying the code to remove the Secure cookie flag in order to run over HTTP is possible under the Apache 2.0 license, but is unsupported. Running the portal without HTTPS exposes login credentials and session tokens to interception on the network.

Why does this differ from OpenSim itself? OpenSim’s own login/viewer protocol is largely unencrypted by design, and that’s a much harder problem to fix than it sounds. It would require a viewer build that enforces HTTPS, cooperation across every independently-run grid, and — critically — would break Hypergrid travel, since HG depends on interoperating with other grids you have no control over and can never guarantee will make the same change. Realistically, that fix is never coming grid-wide.

The portal is different: it’s a single web application under your control, serving a browser, where HTTPS is the modern baseline and free certificates make it a low-effort fix with no interoperability dependencies. Leaving the portal on HTTP because OpenSim itself is HTTP would mean leaving an easy door unlocked because a much harder one exists elsewhere.


Licensing

SVPortal itself is licensed under the Apache License 2.0 — see the LICENSE file at the project root. You are free to use, modify, and redistribute it, including for commercial grids, under that license’s terms.

Two bundled/optional pieces carry their own, different licenses:

  • PHPMailer (includes/phpmailer/PHPMailer.php, SMTP.php, Exception.php) is bundled directly in the release and licensed under LGPL v2.1. Its own LICENSE file ships alongside it in includes/phpmailer/ — LGPL requires that license text to travel with the library, so don’t remove it if you redistribute your own copy of the portal. LGPL v2.1 is compatible with Apache 2.0 for this kind of bundling (PHPMailer is used as a library, not modified).
  • TinyMCE (optional rich text editor for Portal Pages — see TinyMCE) is licensed under GPLv2+, which is not compatible with Apache 2.0 for bundling purposes. For this reason TinyMCE is deliberately excluded from the SVPortal release — the vendor/ directory should not be part of any distributed archive. If you want the rich text editor, download and install TinyMCE yourself — the portal works fully without it either way, falling back to a plain textarea.

New install or upgrade

New install

Follow QuickStart.md. In short, you do five things by hand:

  1. Point the web server at the portal’s public/ directory (with PHP-FPM).
  2. Get a TLS certificate.
  3. Copy config-sample.php to config.php and set only PORTAL_BASE_URL and the DB_* connection details.
  4. Import portal_schema.sql and create the database user with its grants.
  5. Sign in as an Administrator and open Setup.

config.php now holds only:

  • PORTAL_BASE_URL
  • DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_CHARSET
  • USERLEVEL_LABELS (and the helper functions that depend on it)

Everything else that older versions of this document told you to put in config.php — ROBUST host and ports, GRID_NAME, GRID_LOGIN_URI, RemoteAdmin, email, STARTER_AVATARS, folder paths, feature flags — is set in Setup and stored in the portal_settings database table. See Configuring the portal through Setup.

Setting these in config.php is no longer the way to configure them — use Setup. (An older config.php that still contains them is handled by importsettings.php; see below.)

Startup checks before sign-in (“This portal isn’t ready yet”)

The public pages (index, login, splash, register, publicprofile, email_verify, page) run a short set of startup checks via includes/preflight.php before they do anything else. It checks, in order:

  1. config.php exists, is readable, and loads without error.
  2. The required constants are defined and are not the sample values (DB_NAME, DB_USER, DB_PASS; and PORTAL_BASE_URL must not still contain yourgrid.example).
  3. The database connection opens.
  4. The portal’s database user holds the minimum grants needed to sign in: SELECT on UserAccounts; SELECT, INSERT, UPDATE, DELETE on portal_sessions; SELECT, INSERT, UPDATE on portal_settings.

If any check fails, visitors see a generic “This portal isn’t ready yet” page (HTTP 503) with no detail — it is shown to anyone, so it deliberately doesn’t say what is wrong. The real reason is written to the PHP error log on a line beginning SVPortal preflight:. QuickStart.md has a “log line → fix” table.

Only those minimum grants are checked before sign-in. Grants on the other portal_ tables are reported by Setup’s Database tab once you are logged in. Pages behind login do not run these checks.

Upgrading an existing portal

If you already have a config.php containing real values for settings that now live in Setup, do not start by copying config-sample.php over it. Instead, before you first open Setup, run the import script from a shell on the server, from the portal’s public/ directory:

bash

cd /path/to/portal/public
sudo php importsettings.php

The script is command-line only — it refuses to run over the web, and the web server is not involved at all. It reads your existing config.php, imports every real value into portal_settings, shows a diff of what changed, and then tries to trim config.php down to its new shape (commenting out the migrated define() lines) after saving a timestamped backup, config.php.backup-<timestamp>, in the folder above public/ (see below).

Which user to run it as. The import into the database only needs to read config.php. The trim step needs two further permissions: write access to config.php itself, and write access to the folder above public/ (where the backup goes). If you have locked config.php down as recommended (root:www-data, mode 640), only root has both, so run the script with sudo as above. Any other user who happens to have write access to both will also complete the trim — root is not special. If either permission is missing, the import still completes, the trim is skipped with a message (“config.php was NOT trimmed”), and config.php is left unchanged. That is safe — the settings are in the database and the leftover lines in config.php are simply unused — and you can tidy the file by hand later.

The backup. Before trimming, the script saves a copy of your old config.php as config.php.backup-<timestamp> in the folder above public/ (outside the web root), with mode 600, because it contains your database password. It never falls back to writing the backup inside public/: if the parent folder isn’t writable, the trim is skipped instead. Once you’ve checked the result, delete the backup or keep it somewhere safe.

The script’s header comment lists exactly what it does and the one known limitation (it comments out define() statements by counting parentheses rather than using a real PHP parser). Always diff the trimmed config.php against the backup before deleting the backup. Setup will also import individual settings the first time each is used if you skip the script, but running it first is the recommended, all-at-once path and the only way to see a diff of what was imported.

After importing, check Setup’s Database tab (it should show “Database OK”) and work through the other tabs looking for any “Not configured” badges.


Securing config.php and the web server

config.php contains your database password. It must never be downloadable.

bash

chmod 640 config.php
chown root:www-data config.php

Use your web server’s user in place of www-data if different. The web server user must be able to read the file, or the “not ready” page appears.

Add a location block to your Nginx server config to block direct requests to it, before your main location ~ \.php$ block so it takes precedence:

nginx

location = /config.php {
    deny all;
    return 404;
}

With this in place, a direct request for https://yourportal.example.com/config.php returns a 404.

config.php and the files under includes/ are never invoked directly in normal operation (only via require_once from the public entry-point scripts), so PHP-FPM would execute them in isolation and produce no output even without this block. The Nginx rule closes the theoretical gap where a misconfigured FPM pool might echo raw source.

Stricter option. You can instead whitelist only the PHP entry points you intend to be public, and deny every other .php request. This is more robust but is something you must maintain: every time a new public entry-point file is added to the portal, the whitelist needs updating, and a missing entry shows up as a 404 for that page. Most deployments are fine with the single config.php block above.

Secrets in the database. Passwords and access codes you enter in Setup (REMOTEADMIN_PASSWORD, SMTP_PASSWORD, INWORLD_RELAY_ACCESS_CODE) are stored in portal_settings in readable form — the portal has to be able to send them on to RemoteAdmin, your mail server, and so on, so they can’t be one-way hashed. This is why the database user’s credentials and the database itself deserve the same care as config.php. DB_PASS stays in config.php and is not stored in portal_settings.

Setup shows a status line under each secret field (saved / none / still the sample value). An Administrator can click Reveal…, enter their own portal password, and see the saved value in a dialog that closes itself after 60 seconds. Five wrong passwords lock Reveal out for that session for five minutes. Each reveal (and each failed attempt) is recorded in portal_log as setup_secret_revealed / setup_secret_reveal_failed, naming only the setting, never the value. Reveal does not cover DB_PASS.


Database user and grants

Importing the schema and creating the user is covered step by step in QuickStart.md (step 5), including the complete GRANT list. In summary:

  • Back up first, even though portal_schema.sql only adds new tables:

bash

  mysqldump -u root -p your_database > your_database_backup_$(date +%Y%m%d).sql
  mysql -u root -p your_database < portal_schema.sql
  • The portal’s tables all have a portal_ prefix and are safe to add to the same database as your OpenSim/ROBUST tables.
  • The portal user gets SELECT on the whole database and write access only on the portal_ tables.
  • The host part matters. 'webportal'@'localhost' and 'webportal'@'%' are two different accounts in MariaDB. Create the user and grant with the same host part.
  • portal_sessions and portal_settings cannot be skipped. Without their grants nobody can sign in. A missing grant on any other portal_ table only breaks the one feature it belongs to.
  • Without the schema imported at all, you’ll see “Could not save your preference”-style errors (a caught Throwable from missing-table queries) on any portal-owned table.

Optional OpenSim database write features

The portal never writes to OpenSim-owned tables unless you switch on one of the optional write features. The master switch is ALLOW_OS_DATABASE_WRITES, on Setup’s Database tab. The individual feature flags are on the Estates tab (estate creation and deletion, manager edits, maturity changes, region info, and so on) and the General tab (user management, partnerships, clear online status).

Each flag needs write grants on specific OpenSim tables. Next to each flag Setup shows a badge — Grants OK, Grants needed, or Needs OS Database Writes (when the master switch is off) — and, under “Show more information”, a ✓/✗ line for every table and privilege it needs. For any that are missing it prints the exact GRANT statements, with your real database and user names filled in, ready to paste at the mysql prompt. The badge is advice only: it does not stop you switching a flag on, so check it shows Grants OK afterwards, because a flag that is on without its grants will fail when used. Some flags also need the master switch on to have any effect.


OpenSim and ROBUST configuration

These changes are made in your OpenSim installation, not in the portal.

Enable AllowCreateUser and AllowSetAccount in ROBUST

Both default to false in the stock OpenSim config. In Robust.ini (or Robust.HG.ini), under [UserAccountService], set:

ini

AllowCreateUser = true
AllowSetAccount = true

Without these, the ROBUST /accounts endpoint accepts connections but rejects METHOD=createuser and METHOD=setaccount calls. This surfaces in the portal as:

  • “The grid service was unable to apply this change” on admin approval / account creation
  • Similar failures on email-change operations

The exact failure reason only appears in the PHP error log (robust_parse_response(...): result='...'), not in the user-facing message.

If you want to allow users to change their own passwords via the portal, also set in [AuthenticationService]:

ini

AllowSetPassword = true

Then tell the portal where ROBUST is: Setup → ROBUST tab (ROBUST_HOST, ROBUST_PRIVATE_PORT, ROBUST_PUBLIC_HOST, ROBUST_PUBLIC_PORT, and the login, asset and map URLs). Setup checks and reports whether ROBUST responds.

A wrong ROBUST_PRIVATE_PORT surfaces as a cURL-level “Could not connect to the grid service” error (connection refused). That is immediately distinguishable from “right port, ROBUST rejected the call”, which produces an XML-level response with a failure result.

Security note: ROBUST’s account API (/accounts, /auth/plain — account creation and password changes) has no authentication of its own. Unlike RemoteAdmin, which at least requires a password, this API accepts requests from anyone who can reach the port — the port not being reachable from anywhere untrusted is the entire security boundary. If ROBUST_PRIVATE_PORT were ever exposed to the public internet (a misconfigured firewall rule, a cloud security group left open, a reverse proxy accidentally forwarding it), anyone who found it could call METHOD=setpassword with any account’s UUID and set their password to anything with zero credentials, or METHOD=createuser to spam-create accounts. Confirm this port is firewalled to only the machines that need it — never exposed publicly.

Enable RemoteAdmin in each simulator

The portal uses OpenSim’s RemoteAdmin XMLRPC interface for region restarts, broadcasts, OAR/IAR operations, and rolling restarts. RemoteAdmin runs on the same port as the region’s normal HTTP listener (serverPort in the regions table) — no separate port is needed.

In each simulator’s config:

ini

[RemoteAdmin]
enabled = true
port = 0        ; 0 = use the region's own HTTP listener port
access_password = your_remoteadmin_password
enabled_methods = all

The key is access_password (not password). Then enter the same password in Setup → RemoteAdmin & Cron (REMOTEADMIN_PASSWORD, plus REMOTEADMIN_HOST and the enable switch). That tab also has a collapsed box showing the matching OpenSim.ini section.

RemoteAdmin works independently of the simulator’s console mode. The portal’s simulators should run on -console=basic (recommended — gives fast screen -r regionname access) and RemoteAdmin still functions fully.

Set Stats_URI = "jsonSimStats" in your simulator config

Add this to the [Startup] section of your simulator defaults ini (the shared *-Defaults.ini passed via -inimaster=, not the per-region OpenSim.ini):

ini

[Startup]
Stats_URI = "jsonSimStats"

Without it, the simulator’s HTTP server starts with no errors, but /jsonSimStats returns a 404 — meaning the portal’s region online/offline status dots and live stats panel (region modal → Region Stats) silently fail to load data for those regions.

Keep per-region OpenSim.ini files limited to truly per-region values (PIDFile, regionload_regionsdir, http_listener_port). Share everything else via your defaults ini.

Note: some regions may work without this setting explicitly present (possibly inherited from an older OpenSim default or version-specific behaviour), but setting it explicitly is the safe, reproducible fix for any new grid or region.

(Optional) Point the viewer’s Destination Guide at destination_guide.php

destination_guide.php is a standalone, no-auth page listing upcoming Grid Events — plain HTML with its own minimal stylesheet (destination_guide.css), intentionally styled differently from the rest of the portal since it renders inside the viewer’s embedded browser panel. It requires no session and no login, by design.

To make it appear in the viewer’s built-in Destination Guide, add this to Robust.ini (or Robust.HG.ini) under [GridInfoService]:

ini

[GridInfoService]
DestinationGuide = https://yourportal.example.com/destination_guide.php

This is entirely optional. Grid Events are managed the same way either way (Administration → Grid Events); this setting only controls whether the viewer surfaces them via its Destination Guide panel.


Configuring the portal through Setup

Open Administration → Setup (Administrators only). The tabs are:

TabWhat lives there
DatabaseDatabase connection checks, the portal-table grant checklist, ALLOW_OS_DATABASE_WRITES. Shows a green “Database OK” banner when all four checks pass.
ROBUSTROBUST host/ports, public host, login URI, asset and map-tile URLs; whether ROBUST responds.
RemoteAdmin & CronRemoteAdmin host and password; the crontab lines for your install; whether each cron job has run; OAR backup path check.
IdentityGRID_NAME, display name, subtitle, email “from” name. A GRID_NAME of “This Grid”, “My Grid” or “Your Grid Name” counts as a placeholder (red warning and a “Not configured” badge).
EstatesThe region-level and estate-level feature flags (ENABLE_*), each with its grant check.
File SettingsEvery upload, OAR and asset-cache folder and size limit, plus TINYMCE_PATH.
GeneralEmail and SMTP, password policy, registration, sessions, themes, starter avatars, display toggles, and the remaining settings.

Tabs that still need attention carry a badge (for example Not configured, Check cron, Check paths).

Setup’s checklists state what is true in both states — “Database: connected” / “Database: not connected” — so a ✓ or ✗ is never ambiguous.

How saving works

There are two kinds of control, and they save differently:

  • Sliding switches save instantly — the moment you flip one. This covers the Display & Feature Toggles group, the default-theme dropdown, and the feature and OS-write switches (those reload the page to refresh the grant checks).
  • Plain checkboxes are saved by the form’s Save button. Any form with checkboxes carries a note saying so (RemoteAdmin, Email, Password Policy, Everything Else).

If you tick a checkbox and navigate away without pressing Save, the change is lost.

Where did the old config.php setting go?

Used to be set in config.phpNow
ROBUST_*, GRID_LOGIN_URI, asset/map URLsSetup → ROBUST
REMOTEADMIN_HOST, REMOTEADMIN_PASSWORDSetup → RemoteAdmin & Cron
GRID_NAME, display name, subtitleSetup → Identity
ENABLE_* flagsSetup → Estates (region/estate flags) and General (user management, partnerships, clear presence)
ALLOW_OS_DATABASE_WRITESSetup → Database
OAR_*, *_IMAGE_UPLOAD_DIR / _URL / size limits, ASSET_CACHE_DIR, TINYMCE_PATHSetup → File Settings
EMAIL_*, SMTP_*, ADMIN_NOTIFY_EMAILSSetup → General
STARTER_AVATARS, SINGLE_REGION_PER_SIMULATOR, password policy, sessions, themesSetup → General
PORTAL_BASE_URL, DB_*, USERLEVEL_LABELSStill config.php

Folders on disk

All of these are set and checked on Setup → File Settings. Each setting has a one-line description; each folder shows a live ✓/✗ check run as the web server user; and each group has a “How to set this up” guide with the commands pre-filled from your saved values. The commands below are the same ones, for reference. Replace www-data with your web server user and adjust the paths.

The File Settings tab carries a Check paths badge while the OAR backup path is still the default.

Web-servable image folders (events, links, region information)

Event, link and region images must be directly web-servable, so by default they live inside the portal’s own docroot under uploads/. Create each folder and make it writable by the web server:

bash

# Grid Events (Administration → Grid Events)
mkdir -p /path/to/your/portal/uploads/events
sudo chown www-data:www-data /path/to/your/portal/uploads/events
sudo chmod 750 /path/to/your/portal/uploads/events

# Grid Links (Administration → Grid Links)
mkdir -p /path/to/your/portal/uploads/links
sudo chown www-data:www-data /path/to/your/portal/uploads/links
sudo chmod 750 /path/to/your/portal/uploads/links

# Region Information images
mkdir -p /path/to/your/portal/uploads/region_info
sudo chown www-data:www-data /path/to/your/portal/uploads/region_info
sudo chmod 750 /path/to/your/portal/uploads/region_info

These need the php-imagick extension (see Prerequisites) to downscale and re-encode uploads. For events and links, pasting an external image URL instead of uploading a file is still supported and doesn’t need the folder. A link doesn’t need an image at all — without one it’s shown as a styled text button on the splash page.

Maximum upload size and maximum dimension for each are on the same tab.

OAR backup directory

OAR backups are written by the simulator and read (and downloaded) by the web server, so the directory must be accessible to both. This one is deliberately kept outside the web root.

bash

sudo mkdir -p /var/opensim/backups/oar
sudo chown opensim:www-data /var/opensim/backups/oar
sudo chmod 770 /var/opensim/backups/oar

Replace opensim with the OS user your simulator processes run as, and www-data with your web server user. Mode 770 gives the simulator user and the web server’s group full access (read and write), and keeps backups inaccessible to everyone else. The web server side needs write as well as read: it downloads backups via oar_download.php, and the cron processor (running as the web user) writes re-assembled uploaded OARs here.

Set OAR_BACKUP_PATH to this path on Setup → File Settings. A backup job can’t succeed while it is blank or still the default; Setup shows a red message on the OAR job (and a Check cron badge on the RemoteAdmin & Cron tab) until you do. OAR_MAX_BACKUPS_PER_REGION (retention limit) is on the same tab.

Alternative: add the web server user to the simulator’s group instead. Some setups leave this directory owned opensim:opensim (for example it was created by the simulator itself, or inherited from an earlier install). If so, you can leave the ownership alone and add your web server user to the simulator’s group:

bash

sudo usermod -aG opensim www-data
sudo systemctl restart php8.4-fpm   # example only — use your own php-fpm service name,
                                    # which varies with the PHP version; group membership
                                    # is only re-read on a fresh process/session

Cron picks up new group membership automatically on its next invocation (each run is a fresh process); php-fpm does not, until restarted. Either approach achieves the same result: the web server user can traverse the directory via group permissions. Pick whichever fits how the directory already exists; don’t do both unless you’ve checked they don’t conflict. If you take the group route, the directory’s group permissions need to allow what’s described above (770 / group read-write).

Verify this actually works before relying on it. Getting ownership or group wrong here doesn’t produce a permission-denied error anywhere — it produces a much more confusing symptom (see below). Setup’s live check does this for you, but you can also confirm from the shell as the web server user:

bash

sudo -u www-data php -r 'var_dump(file_exists("/var/opensim/backups/oar"));'

This must print bool(true). If it prints bool(false), your web server user cannot traverse into the directory. Diagnose the exact break point with:

bash

namei -l /var/opensim/backups/oar

This walks every directory in the path and shows the permissions/owner of each — look for the first one where your web server user is neither the owner nor a member of the owning group, and has no “other” access either.

Troubleshooting — OAR backups always show “file not found” even though the file clearly exists on disk: this is the exact symptom of the permission problem above, and it’s easy to misdiagnose as a timing bug because the wording sounds like the file just hasn’t been written yet. oar_backup_processor.php intentionally tolerates the file not existing yet early on (large regions can take several minutes to start writing); what it can’t tolerate is the file existing but being invisible to the web server user for the directory’s entire lifetime. Confirm with the file_exists() check above (run as your web server user, not root — ls as root always succeeds and tells you nothing about what the web server user can see).

OAR upload temp directory

The OAR restore feature lets estate owners upload an OAR from their own machine. Uploaded chunks are stored temporarily before the cron processor assembles them.

bash

sudo mkdir -p /tmp/oar_uploads
sudo chown www-data:www-data /tmp/oar_uploads
sudo chmod 750 /tmp/oar_uploads

The directory only needs to be writable by the web server — the cron processor (which assembles the chunks) runs as the same user. The assembled file is written to OAR_BACKUP_PATH. Set OAR_UPLOAD_TEMP_PATH on File Settings if you prefer a location other than the default (sys_get_temp_dir() . '/oar_uploads').

A folder outside /tmp can be safer if your system clears /tmp on a schedule or gives each service a private /tmp (for example systemd PrivateTmp=yes), since either can make chunks vanish or be invisible to cron. If /tmp works on your server, it’s fine to leave it.

Upload sizes. Individual chunk POSTs are ~10 MB each (OAR_CHUNK_SIZE in helpers.php). Your web server only needs to accommodate one chunk per request, not the full OAR file. If you are behind a reverse proxy (e.g. a second Nginx instance), ensure client_max_body_size is at least 15M on both the proxy and the backend. The default of 1 MB causes 413 errors on the first chunk POST.

nginx

client_max_body_size 15M;

PHP’s upload_max_filesize and post_max_size (in php.ini or the php-fpm pool config) must also be at least 15M:

ini

upload_max_filesize = 15M
post_max_size = 15M

Asset cache

ASSET_CACHE_DIR (File Settings) is the folder where the portal keeps converted copies of grid images (profile pictures, map tiles) so it doesn’t have to fetch them from ROBUST every time. Deleting its contents is harmless — images are fetched again as needed. The portal creates the folder itself if the folder above it is writable by the web server; otherwise create it as shown by Setup’s ✓/✗ check and commands.

OAR downloads and Nginx buffering

oar_download.php serves backup files (which can easily be several GB) through a normal PHP request via readfile(), since they live outside the web root and must stay behind the portal’s own auth check. Whether a slow client is something to worry about depends on one Nginx setting:

bash

sudo nginx -T | grep -i fastcgi_buffering

If this returns nothing, buffering is on (Nginx’s default) and you don’t need to do anything further. PHP hands the entire file to Nginx as fast as the local disk/socket allows and is then free to finish — Nginx paces the delivery to the client. A slow client downloading a multi-GB backup just takes a long time for them; it does not hold the PHP-FPM worker open or risk max_execution_time.

If you have explicitly set fastcgi_buffering off; anywhere, a slow client does hold the PHP process open for the full transfer. Add set_time_limit(0); in oar_download.php before the readfile() call and raise fastcgi_read_timeout for this endpoint, otherwise a slow download can be killed mid-transfer and the browser will typically show a generic failed or incomplete download.

To sanity-check server-side read speed for your largest backup, independent of any client’s network speed:

bash

time curl -s -o /dev/null "http://localhost/oar_download.php?id=<id>&csrf=<token>"

To test how a genuinely slow client behaves against your Nginx setup (mainly relevant if you’ve disabled buffering, or want to confirm send_timeout — default 60 s, reset on every successful write, so it only fires on a truly stalled connection — won’t cut off real users):

bash

curl --limit-rate 200k -o /dev/null "https://your-portal-domain/oar_download.php?id=<id>&csrf=<token>"

Cron jobs

SVPortal uses three independent cron scripts for background processing. They live in the cron/ directory (outside the web root, beside public/) and must be registered separately in your system crontab.

Setup generates these lines for you on the RemoteAdmin & Cron tab, using the portal’s real cron/ path. A “Setting up the cron jobs” help section stays open until a job has confirmed running. The lines look like this:

* * * * * php /path/to/portal/cron/restart_queue_processor.php >> /var/log/portal_restart_queue.log 2>&1
* * * * * php /path/to/portal/cron/oar_backup_processor.php >> /var/log/portal_oar_backup.log 2>&1
0 4 * * * php /path/to/portal/cron/portal_cleanup.php >> /var/log/portal_cleanup.log 2>&1

Add them with crontab -e as the web server user (or as root with explicit user flags). Each log file must be writable by the user running the job.

No editing of the scripts is needed in the standard layout. Each script works out the web root itself (const WEBROOT = __DIR__ . '/../public/';), which is correct whenever cron/ sits beside public/. Only edit WEBROOT at the top of each script if you have moved the scripts or the web root so that they are no longer side by side. (If you do, keep that edit when you replace the scripts on upgrade.) Note that a plain relative path such as '../public/' does not work under cron — PHP resolves it against cron’s working directory — which is why the scripts build it from __DIR__.

restart_queue_processor.php (every minute) — fires queued region restarts and rolling restarts. Only needed if RemoteAdmin is enabled. Safe to install before RemoteAdmin is configured — it finds an empty queue and exits.

oar_backup_processor.php (every minute) — manages the OAR backup lifecycle: fires admin_save_oar for scheduled jobs, polls filesize stability to detect completion, and enforces the per-region retention limit (OAR_MAX_BACKUPS_PER_REGION). Only needed if you offer OAR backups to estate owners. Safe to install before any are requested. It needs a real OAR_BACKUP_PATH — see OAR backup directory.

portal_cleanup.php (daily) — consolidated housekeeping. Purges old rows across seven tables (portal_restart_queue, portal_oar_backups, portal_oar_uploads, portal_oar_restore_queue, portal_requests, portal_email_tokens, portal_notifications) — see the script’s own docblock and the “Consolidated Daily Housekeeping” entry in Changelog.md. Safe to install from day one; each job is independently wrapped so an empty or missing table for an unused feature doesn’t cause errors.


Topology: one region per simulator

SVPortal is designed and tested for one region per simulator process. This is the SINGLE_REGION_PER_SIMULATOR setting (Setup → General, default on).

When on, the full feature set is available:

  • Region restart (RemoteAdmin admin_restart)
  • Send message to region (RemoteAdmin admin_broadcast)
  • Rolling restarts
  • Live region stats (/jsonSimStats)

When off, these features are hidden in the UI because they are bound to the simulator process, not to individual regions — in a shared-process topology they would only affect the primary region regardless of which region’s UI you use them from. This is an OpenSim architectural boundary, not a portal limitation.

Recommendation: run one region per simulator process. This is what the portal is built for, and it gives you reliable RemoteAdmin control over each region independently.


Email

Email is optional. With it disabled, account approvals apply immediately without a confirmation step, and no notification emails are sent. Email is configured on Setup → General (email enable, transport, SMTP details, “from” address, ADMIN_NOTIFY_EMAILS); the “from” name is on Identity. The email form uses a plain checkbox, so remember to press its Save button.

The SMTP password is stored readable in portal_settings (see Secrets in the database) and has a Reveal… option.

Gmail SMTP relay (recommended for home / small grids)

If your grid is on domestic broadband, port 25 is almost certainly blocked by your ISP and you have no PTR/SPF/DKIM records. Use Gmail as a smarthost instead.

Requirements:

  • A Gmail account
  • 2-Step Verification enabled on that account
  • An App Password generated for the portal (Google Account → Security → App passwords)
    • New accounts may need to wait a short time after enabling 2FA before App Passwords become available

Values to enter in Setup:

SettingValue
Transportsmtp
SMTP_HOSTsmtp.gmail.com
SMTP_PORT587
SMTP_ENCRYPTIONtls
SMTP_USERNAMEyour.address@gmail.com
SMTP_PASSWORDthe 16-character App Password
EMAIL_FROM_ADDRESSyour.address@gmail.com
ADMIN_NOTIFY_EMAILSaddress(es) that receive new-registration alerts

Alternative: Brevo (formerly Sendinblue)

Brevo offers a free tier (300 emails/day) and works well for grids that can’t use Gmail App Passwords. Sign up, verify your sending domain, and use their SMTP relay credentials in the same Setup fields.


TinyMCE (optional rich text editor)

The Portal Pages feature (for publishing public-facing pages such as a Terms of Service) includes an optional rich text editor. Without TinyMCE installed, the editor degrades gracefully to a plain textarea — the feature is fully functional either way.

TinyMCE is licensed under GPLv2+, which is not compatible with SVPortal’s Apache 2.0 licence for bundling purposes, so it is not included in the release and must be downloaded and installed separately by the grid operator.

Download: go to https://www.tiny.cloud/get-tiny/self-hosted/ and download the TinyMCE Community (free) package. No account or API key is required for self-hosted GPLv2 use. Use the latest version — TinyMCE has changed some toolbar button names between major versions (e.g. formatselect became blocks in TinyMCE 6+), and the portal’s Pages editor config assumes a current release. At the time of writing the latest is TinyMCE 8.7.0 (2026-07-01), which is what this portal was built and tested against.

Install: extract the package into your webroot. The suggested location is vendor/tinymce/, but any web-accessible path works — what matters is that the browser can reach it.

public/
  vendor/
    tinymce/
      tinymce.min.js   ← this file must exist at the configured path
      plugins/
      skins/
      ...

Configure: set TINYMCE_PATH on Setup → File Settings to the web path (not the filesystem path) of the TinyMCE folder — what the browser would request. It must begin with /, for example:

/vendor/tinymce

The portal verifies at runtime that tinymce.min.js exists at that path; if not, the editor silently falls back to a plain textarea. Leave the setting blank to turn TinyMCE off and always use the textarea. A fresh install has TinyMCE off, so no action is needed if you don’t want the rich text editor.


Monit (process supervision)

SVPortal’s rolling restart feature works in conjunction with Monit supervising each simulator process. When InworldRestartShutsDown = true is set in the simulator config, a RemoteAdmin restart command shuts the process down and Monit relaunches it.

Debian 13 / Monit hardening issue

Debian 13 ships Monit with ProtectHome=yes enabled in the systemd unit file by default. This creates a kernel-level namespace restriction that makes /home completely invisible to Monit — not a permission denial (which sudo could override), but a namespace-level invisibility that even root cannot see past when running as the Monit service.

Symptom: Monit fails to start or monitor processes whose executables or pid files are under /home, with errors that look like the path doesn’t exist at all.

Fix: comment out the hardening block in Monit’s systemd unit file:

bash

sudo nano /usr/lib/systemd/system/monit.service

Comment out or remove these lines:

ini

ProtectHome=yes
ProtectSystem=full
PrivateTmp=yes

Then reload:

bash

sudo systemctl daemon-reload
sudo systemctl restart monit

Known OpenSim behaviour notes

These are things the portal has been empirically tested against. If you encounter unexpected behaviour, these notes may save you time.

Maturity settings — regionsettings.maturity is authoritative

OpenSim has two fields that look like region maturity:

  • regionsettings.maturity — the authoritative value (0=General, 1=Moderate, 2=Adult). This is what the in-world Region/Estate dialog writes to and what the portal writes to.
  • regions.access — a derived mirror that the simulator recalculates and overwrites from regionsettings on every region restart. Do not write to regions.access directly. Any change will be silently overwritten on the next restart.

Estate manager changes via direct DB write

When the portal adds or removes estate managers directly in the estate_managers table, the portal fires an admin_estate_reload RemoteAdmin call after each change, which makes the simulator reload estate data from the database immediately, without a restart. If admin_estate_reload is unavailable (simulator offline, RemoteAdmin not yet enabled), the change takes effect on the next restart.

Stale estate_map rows

Over time, your grid’s estate_map table will accumulate rows for regions that no longer exist — regions that were decommissioned without their estate_map entry being cleaned up. This is normal and harmless grid accumulation, not a sign of a misconfigured or buggy grid.

Where it surfaces: only inside the portal’s “Delete Estate” flow, which refuses to delete an estate if it finds any estate_map row for that EstateID, even if the region no longer exists in the regions table. The block message names the orphaned RegionIDs explicitly.

How to clean up: only remove a row once you are confident, from your own knowledge of the grid, that the region is genuinely gone for good and not just temporarily offline or deregistered. Then:

sql

DELETE FROM estate_map WHERE RegionID = 'the-region-uuid-here';

There is no portal button for this by design — “no matching regions row” cannot tell you whether a region was permanently destroyed or is merely offline/deregistered, and that distinction requires human judgement about your grid’s current state.


Troubleshooting

“This portal isn’t ready yet”

A startup check failed before the page could load. The page shows no detail; the reason is in the PHP error log on a line beginning SVPortal preflight: (usually the PHP-FPM log and/or your Nginx error.log, depending on how your server logs PHP errors). QuickStart.md has a table mapping each log line to its fix. Grant changes take effect immediately — no restart of Nginx, PHP-FPM or the portal is needed. Reload after each fix; the page moves on to the next problem, if there is one, until the sign-in page appears.

Login page shows “sign-in is temporarily unavailable”

There are now two related situations:

  • Before the login page loads: if the portal’s database user lacks the grants on portal_sessions or portal_settings (or the schema isn’t imported), the startup checks catch it and you get the “not ready” page above, with the cause in the log.
  • After the login page loads: if session storage fails at runtime — for example the grants were fine at startup but a later change broke them, or a partial grant lets the check pass but real use fails — the login page still loads but shows a yellow warning box saying sign-in is temporarily unavailable, with the form greyed out.

For the yellow box, the portal user needs SELECT, INSERT, UPDATE and DELETE on portal_sessions (sessions are created, read, updated and destroyed on every login/logout, so a partial grant breaks it the same way a missing one does) and SELECT, INSERT, UPDATE on portal_settings.

To confirm this is the cause: check the PHP error log for the line session storage unavailable — it is followed by the real MySQL error (for example SELECT command denied to user ... for table portal_sessions), which tells you exactly which privilege is missing.

To fix it: re-run the relevant GRANT statement(s) from QuickStart.md against your actual database and user (substituting your real names, not the your_database / webportal placeholders), then reload. Grants apply immediately.

A common way to hit this is creating a new, more restricted database user later on and forgetting portal_sessions, since it’s easy to assume “the portal works, so sessions must be fine”.

“Could not connect to the grid service”

Connection-level failure (cURL error). Check ROBUST_PRIVATE_PORT on Setup → ROBUST matches what’s in Robust.ini. This is a different failure class from ROBUST accepting the connection but rejecting the request (which produces an XML-level response).

“The grid service was unable to apply this change”

ROBUST accepted the connection but returned a failure response. Check:

  1. AllowCreateUser = true and AllowSetAccount = true are set in Robust.ini under [UserAccountService]
  2. Your PHP error log for the raw robust_parse_response(...) output — it contains the actual ROBUST error message

Region stats / online status not loading

Check that Stats_URI = "jsonSimStats" is set in your simulator’s defaults ini (see OpenSim and ROBUST configuration). Test by visiting http://your-grid-host:REGION_PORT/jsonSimStats directly in a browser — a 404 means the setting is missing; valid JSON means it’s working.

RemoteAdmin calls fail

Check that the simulator’s [RemoteAdmin] section uses access_password (not password), that enabled = true, and that the password matches the one in Setup → RemoteAdmin & Cron. If you’ve forgotten what you saved in Setup, an Administrator can use Reveal… on that field.

OAR backups say “file not found”

See the permissions troubleshooting note under OAR backup directory.

Offline messages not delivering to a user

Check im_offline.Message for that user’s PrincipalID. A single row with a malformed XML encoding declaration (e.g. encoding="utf-16" when the bytes are actually UTF-8) will cause OpenSim to abort processing the entire queue for that user, leaving all subsequent messages stuck. Delete the malformed row(s) and the rest will deliver on the user’s next login.

Estate manager / ownership changes not showing in-world

See Estate manager changes via direct DB write. If you just made a change via the portal and it’s not showing in-world, admin_estate_reload may have failed silently (simulator offline at the time). Restart the affected regions.

Character set / collation errors, and other MySQL-specific issues

See MySQL-specific notes — charset/collation conversion (including existing latin1 data, not just new installs) and authentication plugin issues together in one place.


MySQL-specific notes

This section collects everything specific to running SVPortal against stock MySQL rather than MariaDB. Most deployments and all documentation elsewhere in this file assume MariaDB defaults; if you’re on MySQL, read this section in full before your first install attempt — several of these issues present as “login doesn’t work” or “can’t save X” with no obvious connection to the actual cause.

Charset/collation errors — new installs (database default not set)

The schema and DB_CHARSET (config.php) are both set to utf8mb4, which works identically on MySQL and MariaDB. If you hit a charset/collation error on a MySQL install, do not change DB_CHARSET to 'utf8' to work around it — in MySQL, utf8 is a legacy alias for utf8mb3 (3-byte max), which cannot store 4-byte characters (emoji, some CJK extension characters). Because db.php runs every connection under STRICT_ALL_TABLES mode, the practical symptom isn’t silent data corruption — MySQL rejects the write outright — but it does mean saving a news post, page, or event description containing an emoji fails with a generic “could not save” error that gives no hint the cause is charset-related.

The usual cause is that the database (not just the tables) was created without an explicit charset, so it fell back to a server default that predates utf8mb4 support (older MySQL installs default to latin1) or a MySQL-8.0-vs-MariaDB collation default mismatch (utf8mb4_0900_ai_ci vs utf8mb4_general_ci — the schema explicitly specifies utf8mb4_general_ci per table, so this is usually only a problem at the database-default level). Check with:

sql

SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME
FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = DATABASE();

and fix the database default (this only affects newly created tables going forward):

sql

ALTER DATABASE your_db_name CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

Charset/collation errors — existing data stuck on latin1

A different and more disruptive case: if you’ve inherited or previously created an OpenSim database on stock MySQL without ever setting an explicit charset, UserAccounts (and typically every other table, including any existing portal_* tables) may already be sitting on latin1_swedish_ci. This is a byte-level encoding on disk, not just a declared setting, so the fix above (which only affects new tables) won’t touch it. Symptoms can include the portal being unable to look accounts up at all — login failing, appearing to reach the database but never matching a valid account — because the connection charset (utf8mb4) and the column charset (latin1) disagree.

Confirm the scope first:

sql

SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_db_name';

If this shows latin1_swedish_ci broadly, assume it affects the whole database.

Take a full backup before doing anything else — this is a real data conversion, not a config change:

bash

mysqldump -u root -p your_db_name > your_db_name_backup_$(date +%Y%m%d).sql

Convert the database and every table to utf8mb4. Use CONVERT TO, not just a charset declaration — CONVERT TO re-encodes the stored bytes, which is what you need since the existing data genuinely is latin1-encoded:

sql

ALTER DATABASE your_db_name CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

ALTER TABLE UserAccounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- Repeat CONVERT TO for every OpenSim table, and every existing portal_* table

Watch for the InnoDB index-length ceiling. utf8mb4 uses up to 4 bytes per character versus latin1‘s 1 byte, so an indexed VARCHAR column that was fine under latin1 can exceed InnoDB’s legacy 767-byte key-prefix limit once converted, producing Error 1071: Specified key was too long. This mainly affects tables using the older ROW_FORMAT=COMPACT/REDUNDANT. Modern MySQL 8 defaults to innodb_large_prefix=ON with ROW_FORMAT=DYNAMIC, which raises the limit substantially, so this is uncommon on a reasonably current MySQL 8 install — if you hit it, shorten the affected index or confirm the table’s row format and innodb_large_prefix setting.

mysql_native_password vs caching_sha2_password

MySQL 8 defaults to the caching_sha2_password authentication plugin, while MariaDB (and older MySQL) commonly default to mysql_native_password. This is a connection-level issue, separate from the charset issues above, with a different failure signature — the PDO connection itself can fail, logged in your PHP/Nginx error log as:

OpenSim portal DB connection failed: ...

(from get_db() in includes/db.php), rather than the Login DB error: ... you’d see from a query-level charset problem. On a fresh install this will also show up as a could not connect to the database line from the startup checks (SVPortal preflight:).

This can happen if PHP’s pdo_mysql/mysqlnd build is older than the plugin support, or if the connection isn’t using SSL and the plugin/server combination requires it. Two ways to resolve it:

  • Set the portal’s MySQL user to mysql_native_password (simplest, no PHP changes):

sql

  ALTER USER 'your_portal_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';
  FLUSH PRIVILEGES;
  • Or confirm your PHP mysqlnd/pdo_mysql is current enough to support caching_sha2_password natively, and leave the plugin as-is.

If you’ve fixed both the charset and the auth plugin and still see connection failures, check the error log for the raw PDOException message — it usually names the specific plugin or SSL requirement being rejected.


Database collation defaults may differ from the schema

portal_schema.sql creates its tables with an explicit utf8mb4_general_ci collation. The database itself, however, is created by OpenSim/ROBUST — not the portal — and ROBUST doesn’t specify a collation explicitly. That means the database’s default collation is whatever your MariaDB/MySQL server defaults to, which may not match utf8mb4_general_ci.

This is expected, harmless in normal operation, and not something you need to pre-emptively fix. As of MariaDB 10.10/11.x, the server default collation changed to utf8mb4_uca1400_ai_ci (a newer, more linguistically accurate Unicode collation). If your database’s default is something other than utf8mb4_general_ci, that’s just the server default, not a misconfiguration:

sql

SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME
FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = DATABASE();

As long as the character set is utf8mb4, a differing collation on OpenSim’s own tables versus the portal’s utf8mb4_general_ci tables is very unlikely to cause a problem in practice — most of the portal’s cross-table queries join on UUID columns, which aren’t affected by collation.

If you do hit an Illegal mix of collations error — which would only happen from a query doing a direct string comparison across a portal table and an OpenSim table with different collations — bring the specific column(s) involved into line, rather than changing the whole database:

sql

ALTER TABLE table_name MODIFY column_name VARCHAR(n) COLLATE utf8mb4_general_ci;

This has not been observed in testing to date; it’s included so the fix is on hand if it ever comes up, not because it’s expected to.


Appendix A — Running on Apache

Apache is not a fully supported installation. Everything in this document and in QuickStart.md is written and tested for Nginx with PHP-FPM, which is what the reference deployment runs. SVPortal is plain PHP with no framework dependency on either web server, so it should work under Apache — treat this appendix as “if you must use Apache, this should work”, not as a tested recipe.

Blocking direct access to config.php

The Apache equivalent of the Nginx location = /config.php block, placed in your VirtualHost config or an .htaccess in the portal’s document root:

apache

<Files "config.php">
    Require all denied
</Files>

For a broader belt-and-braces approach, deny dotfiles and anything under includes/, since none of those are meant to be requested directly by a browser:

apache

<FilesMatch "^\.">
    Require all denied
</FilesMatch>

<Directory "/path/to/portal/public/includes">
    Require all denied
</Directory>

As with Nginx, this is largely belt-and-braces — config.php and the files under includes/ are only ever loaded via require_once from the public entry-point scripts, so a direct request would execute in isolation and produce no useful output. It closes the theoretical gap of a misconfigured PHP handler echoing raw source instead of executing it.

PHP handler

Ensure mod_php or a PHP-FPM proxy (mod_proxy_fcgi) is configured for the portal’s VirtualHost, matching a PHP 8.3/8.4 install with the extensions listed in Prerequisites. With PHP-FPM (recommended):

apache

<FilesMatch \.php$>
    SetHandler "proxy:unix:/run/php/php8.4-fpm.sock|fcgi://localhost"
</FilesMatch>

Adjust the socket path for your PHP version.

HTTPS

The HTTPS requirement applies identically — use mod_ssl with a Let’s Encrypt (certbot --apache) or other valid certificate. The session cookie Secure flag is a browser-side rule, not a web-server one.

Document root and the rest of QuickStart

Point the VirtualHost’s DocumentRoot at the portal’s public/ directory (the same layout as Nginx: cron/ and OARs/ beside public/, not inside it). The remaining QuickStart steps (TLS, config.php, database) are unchanged.

OAR downloads and buffering

The Nginx buffering note under OAR downloads and Nginx buffering doesn’t apply to Apache in the same way — mod_proxy_fcgi has no equivalent toggle. If you’re serving very large OAR files through a reverse proxy in front of Apache, check that proxy’s own buffering and timeout settings.

General note

If you hit an Apache-specific issue not covered here, check whether the underlying cause is actually PHP configuration (extensions, php.ini settings) rather than the web server — most of this document’s troubleshooting applies regardless of which web server is in front of PHP-FPM.


Appendix B — Separating portal tables into their own database

By default SVPortal shares the same database as OpenSim/ROBUST (typically a single database named after your grid). This is intentional and normal, and keeps deployment simple. The portal DB user is granted write access only on the portal_-prefixed tables, so there is no meaningful security argument for separation.

If you are adamant that you want portal tables in a separate database, this is what you would need to change. It is left entirely as an exercise for the operator — it is not a supported configuration.

config.php — add a second set of DB constants (e.g. PORTAL_DB_HOST, PORTAL_DB_NAME, PORTAL_DB_USER, etc.) alongside the existing DB_* block. In the common case of same server, same credentials, only PORTAL_DB_NAME will differ. (Note that the startup checks in includes/preflight.php and Setup’s Database tab assume a single database and would also need adapting.)

db.php — add a second get_portal_db() function using the new constants, with its own static PDO singleton. Ten lines, copy-paste of get_db() with constant names swapped.

news_data.php — the news listing queries do LEFT JOIN UserAccounts ua ON ua.PrincipalID = n.author_uuid to pull author names. With separate databases this join is impossible in SQL. You would fetch news rows first, collect the author_uuid values, run a second query against get_db() with WHERE PrincipalID IN (...), build a lookup array in PHP, and merge. Around 15–20 lines of new code.

public_profile_data.php — the public profile directory query drives from UserAccounts and does INNER JOIN portal_prefs pp ON pp.uuid = ua.PrincipalID. Same problem, same fix: query portal_prefs first for opted-in UUIDs, then WHERE PrincipalID IN (...) against UserAccounts. Another 15–20 lines.

Every other portal table query throughout the codebase — get_db() calls that target portal_* tables become get_portal_db() calls. There are roughly 20–30 call sites to audit and change selectively. This is the most tedious part and the most likely place to introduce a bug (wrong connection for a given table).

The performance impact of replacing the two SQL joins with in-PHP merges is negligible at any realistic grid scale — the datasets are small and both sides hit primary-key indexes. This is not a reason to do or not do the work; it just is not a concern.

Scroll to Top