Assumptions:
The build below makes a few assumuptions you may need to change on your install.
- It is built using Debian 13. Mostly this will be the same on Ubuntu but other Linux Distributions will of course be different. Ths package is not designed for Windows, but it will probably be possible to install on windows but you are on your own there.
- It is designed for Nginx with MariaDB. While it has been installed with Apache too this is not my platform of choice so I cannot provide much support for that.
- Similarly while it should work for MySQL there may be issues in some areas.
- For Apache and MySQL please see the relevant sections at the end which hopefully will provide some assistance but I cannot guarantee you will not have any issues beyond these.
SVPortal — Installation & Deployment Notes
This document covers everything you need to know to deploy SVPortal on a new OpenSimulator grid. It is a living document and will grow as more deployment experience is gathered.
Prerequisites
-
PHP 8.3 or 8.4 with the following extensions:
pdo_mysql— all database access (db.phpand everywhere it’s included)mbstring— multi-byte string handling (admin.php,destination_guide.php,messages.php,password_policy.php,region_broadcast.php); missing this producesFatal error: Uncaught Error: Call to undefined function mb_substr()(ormb_strlen(), etc.) rather than a friendlier warningcurl— 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 producesFatal 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):
sudo apt install php8.4-mysql php8.4-mbstring php8.4-curl php8.4-imagick php8.4-fileinfo php8.4-xml(
php8.4-xmlprovidessimplexml,dom, and a few other XML-related extensions bundled together on Debian/Ubuntu — there’s no separatephp8.4-simplexmlpackage.)fileinfo,simplexml,ctype,json, andsessionall 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. Runphp -mand 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) — MySQL also works but expect some collation/charset differences from what’s documented here, which assumes MariaDB defaults; see the Troubleshooting section
-
Nginx (or Apache — Nginx assumed throughout)
-
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 (Nginx/Apache) 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 ownLICENSEfile ships alongside it inincludes/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 the TinyMCE section further down) 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 (see the TinyMCE section below) — the portal works fully without it either way, falling back to a plain textarea.
Quick Checklist — New Grid Deployment
These are the steps most likely to be missed when standing the portal up on a grid other than the one it was developed against. Do all of these before your first test login.
1. Copy config-sample.php to config.php and fill in your grid’s settings
The release ships config-sample.php — copy it to config.php and edit that copy;
config.php is where you put real credentials and it should never be committed to
version control or redistributed.
cp config-sample.php config.php
At minimum, update these settings to match your grid:
ROBUST_HOST— hostname/IP of your ROBUST service (oftenlocalhost)ROBUST_PUBLIC_HOST— your grid’s public hostname (e.g.grid.example.com)ROBUST_PRIVATE_PORT— ROBUST’s private port (default8003)ROBUST_PUBLIC_PORT— ROBUST’s public/viewer-login port (default8002)GRID_NAME,GRID_LOGIN_URI,PORTAL_BASE_URLREMOTEADMIN_HOST,REMOTEADMIN_PASSWORD(if using RemoteAdmin features)STARTER_AVATARS— array of'Display Name' => 'Description'for new-account templates
A mismatched ROBUST_PRIVATE_PORT surfaces as a cURL-level “Could not connect to the grid service” error (connection refused, not an XML/API-level failure). This is immediately distinguishable from “right port, ROBUST rejected the call” (which produces an XML-level response with a failure result).
2. Secure config.php
chmod 640 config.php
chown root:www-data config.php
config.php contains your database credentials, ROBUST password, RemoteAdmin password, and SMTP credentials — it must never be directly downloadable from the web.
Add a location block to your Nginx server config to block direct requests to it:
location = /config.php {
deny all;
return 404;
}
Place this block before your main location ~ \.php$ block so it takes precedence. With this in place, a direct request for https://yourportal.example.com/config.php returns a 404 rather than executing or serving the file.
A more defensive alternative that covers any file starting with a dot or any .php file that isn’t meant to be directly accessible (e.g. db.php, auth.php, helpers.php) is to whitelist only the PHP entry points you intend to be public:
# Only these files are callable directly
location ~ ^/(index|login|logout|register|register_avatars|profile|publicprofile|
account|messages|friends|regions|region_admin|region_restart|
region_broadcast|region_stats|rolling_restart|estates|estate_tools|
all_estates|admin|admin_users|change_password|change_maturity|
splash|email_verify|partner_action|news_data|profile_data|
public_profile_data|region_stats_data|register_avatars_data|
notifications_data|inworld_checkin|region_image|profile_image|
region_restart_history|region_status|theme_loader|assets|destination_guide)\.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Block everything else
location ~ \.php$ {
deny all;
return 404;
}
The whitelist approach is more robust but requires updating when new entry-point files are added. The single config.php deny block is the lower-maintenance option and sufficient for most deployments — config.php and the include files it pulls in are never invoked directly anyway (only via require_once from other scripts), so PHP-FPM would execute config.php in isolation and produce no output even without the block. The nginx rule closes the theoretical gap where a misconfigured FPM pool might echo raw source.
3. Run portal_schema.sql
Run the portal schema against your grid’s database before first use.
Take a backup first. Even though portal_schema.sql only adds new tables and never touches existing ones, it is good practice to snapshot your grid database before any schema modification:
mysqldump -u root -p your_database > your_database_backup_$(date +%Y%m%d).sql
Then import the portal schema:
mysql -u root -p your_database < portal_schema.sql
Without this, the portal will throw “Could not save your preference”-style errors (caught Throwable from missing-table queries) on any portal-owned table. The portal-owned tables all have a portal_ prefix — they are safe to add to the same database as your OpenSim/ROBUST tables.
Grant the portal DB user the necessary permissions:
CREATE USER 'webportal'@'localhost' IDENTIFIED BY 'your_password';
GRANT SELECT ON your_database.* TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_prefs TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_notifications TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_news TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_events TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_pages TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_links TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_log TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_email_tokens TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_pending_registrations TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_restart_queue TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_oar_backups TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_oar_uploads TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_requests TO 'webportal'@'localhost';
GRANT INSERT, UPDATE, DELETE ON your_database.portal_oar_restore_queue TO 'webportal'@'localhost';
GRANT SELECT, INSERT, UPDATE ON your_database.portal_inworld_relay TO 'webportal'@'localhost';
FLUSH PRIVILEGES;
If you have enabled the OpenSim database write features (ALLOW_OS_DATABASE_WRITES = true in config.php, which you set in step 1 above), you will also need write grants on specific OpenSim-owned tables — one or more, depending on which ENABLE_* flags you’ve turned on. Each flag’s docblock in config.php documents exactly which table(s) it writes to and the exact GRANT statement needed, right next to the define() for that flag — check the docblock for every ENABLE_* flag you’ve set to true. A few tables (estate_settings, estate_managers) are written to by more than one flag; if you enable more than one of those flags, combine the privileges into a single GRANT per table rather than issuing repeat GRANT statements against the same table.
4. 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:
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 — check your error log first when debugging ROBUST connectivity issues.
If you want to allow users to change their own passwords via the portal, also set in [AuthenticationService]:
AllowSetPassword = true
5. 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):
[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.
6. (Optional) Point the OpenSim 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 rather than a normal desktop browser tab. It requires no session and no login, by design — it must be reachable by visitors who haven’t signed in yet.
To make it appear in the viewer’s built-in Destination Guide, add this to Robust.ini (or Robust.HG.ini) under [GridInfoService]:
[GridInfoService]
DestinationGuide = https://yourportal.example.com/destination_guide.php
This is entirely optional — the portal functions normally without it. 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.
Grid Event image upload directory
Event images uploaded via Administration → Grid Events are stored under EVENT_IMAGE_UPLOAD_DIR in config.php (default uploads/events inside the portal’s own docroot, since — unlike OAR backups — these images must be directly web-servable). Create it and make it writable by the web server:
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
Replace www-data with your web server user and adjust the path to match your actual docroot. Requires the php-imagick extension (already a prerequisite — see the top of this document) to downscale and re-encode uploaded images. Pasting an external image URL instead of uploading a file remains supported and doesn’t need this directory at all.
Grid Links image upload directory
Same pattern as Grid Event images above. Link images uploaded via Administration → Grid Links are stored under GRID_LINK_IMAGE_UPLOAD_DIR in config.php (default uploads/links inside the portal’s own docroot):
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
A link doesn’t need an image at all — without one it’s simply shown as a styled text button on the splash page instead of a thumbnail.
Topology — One Region Per Simulator
SVPortal is designed and tested for one region per simulator process. This is controlled by the SINGLE_REGION_PER_SIMULATOR constant in config.php (default true).
When SINGLE_REGION_PER_SIMULATOR = true, 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 SINGLE_REGION_PER_SIMULATOR = false, these features are hidden in the UI because they are bound to the simulator process, not to individual regions — meaning they would only affect the primary region in a shared-process topology 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 Setup (Optional but Recommended)
Email is optional. Set EMAIL_ENABLED = false in config.php if you don’t want to configure it. With email disabled, account approvals apply immediately without a confirmation step, and no notification emails are sent.
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)
- Note: new accounts may need to wait a short time after enabling 2FA before App Passwords become available
config.php settings:
define('EMAIL_ENABLED', true);
define('EMAIL_TRANSPORT', 'smtp');
define('SMTP_HOST', 'smtp.gmail.com');
define('SMTP_PORT', 587);
define('SMTP_ENCRYPTION', 'tls');
define('SMTP_USERNAME', 'your.address@gmail.com');
define('SMTP_PASSWORD', 'xxxx xxxx xxxx xxxx'); // 16-character App Password
define('EMAIL_FROM_ADDRESS', 'your.address@gmail.com');
define('EMAIL_FROM_NAME', 'Your Grid Name');
define('ADMIN_NOTIFY_EMAILS', ['admin@example.com']); // receives 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 config.php settings above.
TinyMCE (Optional — Rich Text Editor for Portal Pages)
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. For this reason TinyMCE is not included in the SVPortal 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. Make sure
you grab the latest version rather than an older cached copy — TinyMCE has changed
some toolbar button names between major versions (e.g. the heading/paragraph dropdown
was renamed from formatselect to blocks in TinyMCE 6+), and the portal’s admin
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.
Installation:
Extract the downloaded package into your webroot. The suggested location is a
vendor/tinymce/ subdirectory, but any web-accessible path works — what matters
is that the path is reachable by the browser, not where it sits on disk.
Suggested structure:
public/
vendor/
tinymce/
tinymce.min.js ← this file must exist at the configured path
plugins/
skins/
...
Configuration:
Set TINYMCE_PATH in config.php to the web path (not filesystem path) of the
TinyMCE folder — i.e. what the browser would request:
define('TINYMCE_PATH', '/vendor/tinymce');
The portal verifies at runtime that tinymce.min.js exists at the configured path.
If the file is not found, the editor silently falls back to a plain textarea.
Set TINYMCE_PATH to false to disable TinyMCE entirely and always use the textarea:
define('TINYMCE_PATH', false);
The distributed config.php defaults to false — no action needed if you do not
want the rich text editor.
RemoteAdmin
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 configuration is needed.
Enable RemoteAdmin in each simulator’s config:
[RemoteAdmin]
enabled = true
port = 0 ; 0 = use the region's own HTTP listener port
password = your_remoteadmin_password
enabled_methods = all
Set REMOTEADMIN_PASSWORD in config.php to match.
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.
Cron Jobs
SVPortal uses two independent cron scripts for background processing. Both live in the cron/ directory (outside the web root) and must be registered separately in your system crontab.
Add both entries with crontab -e (as the web server user, or as root with explicit user flags):
* * * * * 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
Replace /path/to/portal/ with your actual portal path. Both scripts also have a WEBROOT constant near the top that must match your actual webroot path (e.g. /var/sites/example.com/portal/public/).
restart_queue_processor.php — fires queued region restarts and rolling restarts. Only needed if RemoteAdmin is enabled. Safe to install even before RemoteAdmin is configured — it simply finds an empty queue and exits.
oar_backup_processor.php — manages the OAR backup lifecycle: fires admin_save_oar for scheduled jobs, polls filesize stability to detect completion, enforces the per-region backup retention limit (OAR_MAX_BACKUPS_PER_REGION in config.php). Only needed if you intend to offer OAR backups to estate owners. Safe to install before any backups are requested.
OAR backup directory
Create the OAR backup directory and set ownership so the OpenSim simulator process can write to it and the web server can read from it:
sudo mkdir -p /var/opensim/backups/oar
sudo chown opensim:www-data /var/opensim/backups/oar
sudo chmod 750 /var/opensim/backups/oar
Replace opensim with whatever OS user your simulator processes run as, and www-data with your web server user. The 750 permission gives the simulator user full control and the web user read access (needed by oar_download.php to serve files), while keeping backups inaccessible to all others.
Alternative: add the web server user to the simulator’s group instead. Some setups leave this directory owned opensim:opensim (e.g. it was created by the simulator itself, or inherited from an earlier install) rather than explicitly re-chowning it to opensim:www-data. If so, it’s just as valid to leave the directory’s ownership alone and instead add your web server user to the simulator’s group:
sudo usermod -aG opensim www-data
sudo systemctl restart php8.3-fpm # or your php-fpm service name — 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 — chown the directory, or add the web user to the simulator’s group — achieves the same result: the web server user becomes able to traverse the directory via its group permissions. Pick whichever fits how the directory already exists on your system; don’t do both unless you’ve checked they don’t conflict (e.g. don’t also loosen the directory to 750 for opensim:www-data if you’ve instead added www-data to the opensim group and left the directory opensim:opensim — either is sufficient alone).
Set OAR_BACKUP_PATH in config.php to match this path.
Verify this actually works before relying on it. Getting the ownership/group wrong here doesn’t produce a permission-denied error anywhere — it produces a much more confusing symptom (see below), so don’t just trust that the chown/chmod/usermod commands ran; confirm the web server user can genuinely see into the directory:
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 — usually because its actual owner/group doesn’t match what you intended (e.g. a directory cloned or inherited from an earlier setup, still owned by opensim:opensim with no group access for the web server user at all). Diagnose the exact break point with:
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. Fix with whichever of the two approaches above fits your setup: chown the directory to include your web server user’s group, or usermod -aG your web server user into the directory’s existing group (remembering to restart php-fpm afterwards).
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 (“file not found… during processing”) sounds like the file just hasn’t been written yet. It hasn’t —
oar_backup_processor.phpintentionally 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, which is what a group/ownership mismatch onOAR_BACKUP_PATHcauses. Confirm with thefile_exists()check above (run as your actual web server user, not root —lsandsudoas root will always succeed and tell you nothing about what the web server user can see) before assuming this is a code or timing issue.
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 connection is something you need to worry about here depends entirely on one Nginx setting:
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. With buffering on, PHP hands the entire file to Nginx as fast as the local disk/socket allows and is then free to finish — Nginx separately paces the actual delivery to the client at whatever speed their connection allows. In this (default, recommended) setup, a slow client downloading a multi-GB backup just takes a long time for them; it does not hold the PHP-FPM worker open and does not risk hitting max_execution_time.
If you have explicitly set fastcgi_buffering off; anywhere in your config (uncommon, sometimes done for other reasons like real-time streaming elsewhere on the same server), a slow client does hold the PHP process open for the full transfer, and you should add set_time_limit(0); in oar_download.php before the readfile() call, plus raise fastcgi_read_timeout for this endpoint specifically — otherwise a slow download can be killed mid-transfer, and the browser will typically show a generic failed/incomplete download rather than anything pointing at the actual cause.
You can sanity-check server-side read speed for your own largest backup directly, independent of any client’s network speed:
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 specific Nginx setup (mainly relevant if you’ve disabled buffering, or want to confirm send_timeout — default 60s, resets on every successful write, so only fires on a truly stalled connection rather than a merely slow one — isn’t going to cut off real users):
curl --limit-rate 200k -o /dev/null "https://your-portal-domain/oar_download.php?id=<id>&csrf=<token>"
OAR upload temp directory
The OAR restore feature allows estate owners to upload an OAR file from their local machine. Uploaded chunks are stored temporarily before the cron processor assembles them. Create the temp directory and set ownership so the web server can write to it:
sudo mkdir -p /tmp/oar_uploads
sudo chown www-data:www-data /tmp/oar_uploads
sudo chmod 750 /tmp/oar_uploads
Replace www-data with your web server user. 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, which already exists from
the backup directory setup above.
Set OAR_UPLOAD_TEMP_PATH in config.php to match this path if you prefer a
location other than the default (sys_get_temp_dir() . '/oar_uploads').
Note: Individual chunk POSTs are ~10 MB each (configurable via
OAR_CHUNK_SIZEinhelpers.php). Your web server only needs to accommodate one chunk per request, not the full OAR file size. If you are behind a reverse proxy (e.g. a second Nginx instance), ensureclient_max_body_sizeis set to at least15Mon both the reverse proxy and the backend server. The default of 1 MB will cause 413 errors on the first chunk POST.
# In your reverse proxy server block:
client_max_body_size 15M;
# In your backend portal server block:
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:
upload_max_filesize = 15M
post_max_size = 15M
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:
sudo nano /usr/lib/systemd/system/monit.service
Comment out or remove these lines:
ProtectHome=yes
ProtectSystem=full
PrivateTmp=yes
Then reload:
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 fromregionsettingson every region restart. Do not write toregions.accessdirectly. Any change will be silently overwritten on the next restart.
Estate Manager Changes Require a Region Restart (via Direct DB Write)
When the portal adds or removes estate managers directly in the estate_managers table, the running simulator does not pick up the change until the region is restarted. This is expected — the portal now fires an admin_estate_reload RemoteAdmin call after each change, which causes the simulator to reload estate data from the database immediately without a restart. If admin_estate_reload is unavailable (simulator offline, RemoteAdmin not yet enabled), the change will take 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 will refuse 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 will name 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:
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
“Could not connect to the grid service”
Connection-level failure (cURL error). Check ROBUST_PRIVATE_PORT in config.php 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:
AllowCreateUser = trueandAllowSetAccount = trueare set inRobust.iniunder[UserAccountService]- Check your PHP error log for the raw
robust_parse_response(...)output — it will contain 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 step 4 of the Quick Checklist above). 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.
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.
Stale estate manager / ownership changes in-world
See the “Estate Manager Changes Require a Region Restart” note above. If you just made a change via the portal and it’s not showing in-world, the 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 the dedicated MySQL-Specific Notes section near the end of this document —
it covers charset/collation conversion (including existing latin1 data, not just
new installs) and authentication plugin issues together in one place.
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 — every
comparable OpenSimulator portal does the same thing, and it 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, here is what you would need to change. This 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.
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.
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 — when the grid is first set up, 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 from utf8mb4_general_ci/utf8mb4_unicode_ci to utf8mb4_uca1400_ai_ci
(a newer, more linguistically accurate Unicode collation). If you check your
database’s default collation and see something other than utf8mb4_general_ci
— utf8mb4_uca1400_ai_ci on newer MariaDB being the most likely case — that’s
just the server default, not a misconfiguration:
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 (it should be, per the MySQL
notes above), 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 — the fix is to bring the
specific column(s) involved into line, rather than changing the whole
database:
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 here so the fix is on hand if it ever comes up, not because it’s expected to.
MySQL-Specific Notes
This section collects everything that’s 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 — utf8mb4 isn’t MariaDB-specific, it originated
in MySQL. 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 of that mismatch 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:
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, it won’t retroactively touch existing ones):
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 in the database, including any
existing portal_* tables) may already be sitting on latin1_swedish_ci —
MySQL’s historic default. 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, correctly set in
config.php) and the column charset (latin1) disagree.
Confirm the scope first:
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,
not just one table.
Take a full backup before doing anything else — this is a real data conversion, not a config change, and it should not be attempted without a restore path:
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 actual stored bytes,
which is what you need here since the existing data genuinely is latin1-encoded
(declaring the column utf8mb4 without CONVERT TO would leave the bytes
misinterpreted):
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
— but if you hit it, either 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, entirely separate from the charset issues above,
and produces a different failure signature — the PDO connection itself can
fail, logged in your PHP/Apache/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.
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 in that configuration. Two ways to resolve it:
- Set the portal’s MySQL user to
mysql_native_password(simplest fix, no PHP changes needed):ALTER USER 'your_portal_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password'; FLUSH PRIVILEGES; - Or confirm your PHP
mysqlnd/pdo_mysqlis current enough to supportcaching_sha2_passwordnatively, and leave the plugin as-is.
If you’ve fixed both the charset (above) and the auth plugin and are still
seeing connection failures, check the error log for the raw PDOException
message — it will usually name the specific plugin or SSL requirement that’s
being rejected.
Running on Apache
Everything elsewhere in this document assumes Nginx, since that’s what the reference deployment (Sub-Version Space / Ardenthouse) runs. SVPortal works fine under Apache — plain PHP, no framework dependency on either web server — but a few of the Nginx-specific snippets earlier in this doc need translating.
Blocking direct access to config.php
Nginx section 2 above shows a location = /config.php { deny all; return 404; }
block. The Apache equivalent, placed in your VirtualHost config or an
.htaccess in the portal’s document root:
<Files "config.php">
Require all denied
</Files>
For the more defensive whitelist-only approach (only serving the intended
public entry-point .php files, denying everything else), Apache doesn’t have
a direct equivalent of Nginx’s regex location block in the same style —
the simplest reliable approach is to deny all dotfiles and any file under
includes/ from being served directly, since none of those are meant to be
requested directly by a browser:
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<Directory "/path/to/portal/includes">
Require all denied
</Directory>
As with the Nginx version, this is largely belt-and-braces — 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 a direct
request to them would just execute in isolation and produce no useful output
even without this block. 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 whichever PHP 8.3/8.4 install has the extensions
listed in Prerequisites. If using PHP-FPM (recommended, matches the Nginx
reference setup):
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php/php8.4-fpm.sock|fcgi://localhost"
</FilesMatch>
HTTPS
The HTTPS Requirement in Prerequisites applies identically
under Apache — use mod_ssl with a Let’s Encrypt (certbot --apache) or other
valid certificate. There’s nothing Apache-specific about the session cookie
Secure flag behaviour; it’s a browser-side rule, not a web-server one.
OAR downloads and buffering
The Nginx-specific buffering note in the OAR Backup section (fastcgi_buffering)
doesn’t apply to Apache in the same way — Apache’s mod_proxy_fcgi doesn’t have
an equivalent toggle, and Apache’s own request handling doesn’t hold the PHP-FPM
worker open in the same way Nginx’s buffering setting controls. If you’re serving
very large OAR files through a reverse proxy in front of Apache, check that
proxy’s own buffering/timeout settings rather than looking for an Apache
equivalent of the Nginx directive.
General note
If you hit an Apache-specific issue not covered here, it’s worth checking
whether the underlying cause is actually PHP configuration (extensions,
php.ini settings) rather than the web server — most of this document’s
troubleshooting content applies regardless of which web server is in front
of PHP-FPM.
