Setting up SepoDesk without XAMPP
XAMPP is only a bundle of three things: Apache, PHP, and MariaDB. Dropping it means
supplying each one yourself. Nothing in SepoEngine depends on XAMPP — the only places it shows up
are the Windows paths in .env, the Apache error.log that [ResourceQueryBuilder] writes to, and
the localhost/wordpress/sepodesk sub-path that RouterSettings has to resolve.
This guide has three parts:
- Part 1 — local development without XAMPP
- Part 2 — live deployment on Hostinger shared hosting (hPanel)
- Part 3 — Hostinger VPS, and when you actually need it
Read Part 2, Step 0 before you buy anything. There is one architectural constraint that decides which Hostinger plan you need.
Part 0 — What SepoDesk actually requires
Whatever stack you choose, these are the hard requirements.
PHP 8.2 or newer, with these extensions:
pdo_mysql— the auth and app connections throughPdoFactorypdo_sqlite— the dual-driver paths inInstallerServiceanduploadSchemaopenssl—CryptoAES(encrypted role DB passwords) and TLS on remote updatesmbstring— Markdown parsing, front matter,safeHtmljson— every file-backed store (PollStore,FormSubmissionStore,MembershipStore, …)zip—SystemUpdaterunpacking update packagesfileinfo—FileUploadBoardupload validationcurlorallow_url_fopen— fetching remote updates fromtsp_sys_updates_api
MariaDB 10.4.3+ or MySQL 5.7.8+. Not negotiable: compileScope() in ResourceQueryBuilder
emits JSON_VALID, JSON_CONTAINS and JSON_QUOTE. Older servers will fail every scoped query.
A web server that routes unknown paths to index.php — Apache/LiteSpeed via .htaccess, or
Nginx via try_files.
Writable directories (the file-backed stores create subfolders at runtime):
storage/cache/ RoleDbAccessCache, ApiKeyRoleCache
Storage/Backups/ update backups + restore-manifest.json
migration/journal/ installer logs
content/ pages, issues, lms, memberships, form_submissions, polls_votes
A place for PHP errors to land. You lose XAMPP's logs/php_error_log, and with it every
[ResourceQueryBuilder] runStatement line you rely on for silent-fetch debugging.
Part 1 — Local development without XAMPP
Step 1 — Install PHP on its own
Windows. Download the Thread Safe x64 ZIP from windows.php.net, unzip to C:\php, add
C:\php to your PATH. Then copy php.ini-development to php.ini and uncomment these lines
(remove the leading ;):
extension_dir = "ext"
extension=pdo_mysql
extension=pdo_sqlite
extension=openssl
extension=mbstring
extension=zip
extension=fileinfo
extension=curl
error_log = C:\php\logs\php_error.log
log_errors = On
display_errors = On ; dev only
Linux (Debian/Ubuntu):
sudo apt install php8.3-cli php8.3-fpm php8.3-mysql php8.3-sqlite3 \
php8.3-mbstring php8.3-zip php8.3-curl php8.3-xml
macOS: brew install php
Step 2 — Verify the extensions before anything else
php -v
php -m
Every extension from Part 0 must appear in php -m. If pdo_mysql is missing you will get a
could not find driver exception out of PdoFactory::get() — and because that method converts
PDOException into a plain \Exception, your upstream catch (\PDOException) blocks will not fire
and the error will surface somewhere unhelpful. Check the list now, not later.
Step 3 — Install MariaDB
Windows: the MariaDB MSI installer (mariadb.org/download). During setup, tick "Enable access
from remote machines" only if you need it, and set a root password you will remember.
Linux:
sudo apt install mariadb-server
sudo mysql_secure_installation
Confirm the version meets the JSON-function floor:
mysql -u root -p -e "SELECT VERSION();"
Step 4 — Decide: standalone or WordPress mode
This is the biggest single decision, and leaving XAMPP is the natural moment to make it.
Standalone (WORDPRESS_MODE=false) — recommended. SepoDesk serves itself from its own
index.php. One root, one base path, and app_url / assets_url / plugin_url finally agree
instead of pointing at two different roots. The auth.wp_bridge and auth.logout_cleanup views go
unused.
WordPress mode (WORDPRESS_MODE=true). You keep the plugin bridge, but you must also install
WordPress on the new stack, and RouterSettings still has to resolve the split between the routing
base (/wordpress/sepodesk) and the asset base (/wordpress/wp-content/plugins/sepodesk).
If you keep WordPress mode, remember your CSS/JS build script
requireswp-load.php. In standalone mode that file does not exist and the bundler will fatal. Guard the include, or point the script at a bootstrap that works either way, before you run a build on the new box.
Step 5 — Write the .env
SpEnvLoader::env() throws on a missing key. Any ?? 'default' written after it is dead code.
A fresh machine is exactly where you discover this, as a blank 500 on first boot.
So: grep the codebase for env( and make sure every key that comes back exists in the new file.
The ones already known to be in use:
# --- database ---
AUTH_DB_DRIVER=mysql
AUTH_DB_HOST=127.0.0.1
AUTH_DB_NAME=sepodesk_auth
AUTH_DB_USER=root
AUTH_DB_PASS=
# --- crypto ---
# CryptoAES key. MUST be copied byte-for-byte when you migrate hosts.
AES_KEY=<your existing key — do not regenerate>
# --- app / routing ---
WORDPRESS_MODE=false
TRUSTED_HOSTS=localhost,127.0.0.1
TRUST_PROXY=false
# --- session / cookies ---
COOKIE_SAMESITE=Lax
COOKIE_SECURE=false
SESSION_TIMEOUT=7200
# --- subsystems ---
INSTALLER_KEY=<long random string>
UPDATER_ALLOW_INSECURE=true
Two warnings on this file:
AES_KEYis load-bearing. Role DB passwords are stored encrypted in the auth database. If the key on the new machine differs,CryptoAES::decrypt()silently returns its input rather than failing, so you get a bewilderingAccess denied for userfrom MySQL instead of a crypto error. Copy the key across with the database.UPDATER_ALLOW_INSECURE=trueis a local-only flag so the updater will accepthttp://. It must befalsein production.
Step 6 — Create the writable directories
mkdir -p storage/cache Storage/Backups migration/journal
mkdir -p content/pages content/issues content/lms content/memberships
Linux, when serving through PHP-FPM:
sudo chown -R www-data:www-data storage Storage migration content
sudo find storage Storage migration content -type d -exec chmod 775 {} \;
Windows: no ownership step needed; just confirm the folders are not read-only.
Step 7 — Run it
Pick one of the three.
Option A — PHP built-in server (fastest, dev only)
Create server.php in the project root so static assets still serve:
<?php // server.php — router script for `php -S`
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path !== '/' && is_file(__DIR__ . $path)) {
return false; // let the built-in server serve the file
}
require __DIR__ . '/index.php';
php -S localhost:8000 server.php
The app is now at the root, so app_base_path is '' and the two-root asset problem
disappears.
The one real limitation: the built-in server is single-threaded. Any request where SepoDesk
calls back into itself — a remote-update fetch pointed at localhost, for example — will hang until
it times out. If your PHP build supports it, run with PHP_CLI_SERVER_WORKERS=4 set.
Option B — Nginx + PHP-FPM (closest to production)
sudo apt install nginx php8.3-fpm
Use the config in Appendix B, then:
sudo nginx -t && sudo systemctl reload nginx
Option C — Apache + PHP-FPM installed separately
Same .htaccess you already have, just without the bundle. On Windows this is the smallest mental
jump from XAMPP: install httpd, enable mod_rewrite and mod_proxy_fcgi, point a vhost at the
project.
Step 8 — Create the databases and run the installer
CREATE DATABASE sepodesk_auth CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE sp_db_apps CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Then hit the INSTALLER_KEY-gated InstallerServiceController endpoints to install the table
versions from migration/versions, and run uploadSchema for each app's schema.json.
Check migration/journal afterwards — a partial install shows up there before it shows up in the UI.
Step 9 — Smoke test in this order
/loads the SPA shell andsepo-core.min.jsreturns 200 (not 404 — that meansassets_urlis wrong)- Log in — exercises
AuthSessionController, session fingerprinting, the refresh-token cookie /admin/dashboard— exercisesRegistry, resource definitions,ResourceQueryBuilder- Open a scoped resource (
users) — confirms theSCOPEblock and the JSON functions work /docs— confirmsmountFiles, the partials, and the store wiring- Submit a poll or form as a guest — confirms the
PUBLIC_ROUTESwhitelist survived the move - Upload a file — confirms
fileinfoand the upload directory permissions
Part 2 — Hostinger (shared / hPanel)
Step 0 — Read this before choosing a plan
Two constraints on Hostinger shared hosting collide with SepoEngine's architecture:
1. Your PHP cannot create databases. <cite index="28-1">Hostinger explicitly rejects imports containing CREATE DATABASE or DROP DATABASE, anything needing SUPER privileges such as GRANT ALL PRIVILEGES, and any DEFINER, PROCEDURE or TRIGGER lines — and points you at a VPS plan if your project needs SUPER privileges.</cite> Your InstallerService creates databases. On shared hosting you must pre-create every database in hPanel and skip the create-DB step, letting the installer only create tables in databases that already exist.
2. Databases are one-user-per-database. <cite index="23-1">In hPanel each database is created together with its own user, the u123456789 prefix is fixed and cannot be changed, and each database can only have one user — Hostinger's own guidance is to move to a VPS if you need more.</cite>
Constraint 2 is the one that matters most for you. enrollments.php and transcript.php
cross-database join tsp_sys_users in the auth database, qualified through
SpEnvLoader::env('AUTH_DB_NAME'). That join requires the connecting user to hold SELECT on
both databases. With per-database users on shared hosting, it will fail with an access-denied
error that looks nothing like a permissions problem.
Your two options:
- Consolidate to a single database. Put the auth tables and the app tables in one Hostinger
database, set
AUTH_DB_NAMEto it, and the cross-DB joins become same-DB joins. This works on the cheapest plan. It does mean your role-to-target-DB resolver only ever resolves to one target. - Go VPS (Part 3) if you want to keep the multi-database design intact.
Also note: <cite index="19-1">SSH is available on all plans except Single and WordPress Single</cite>, and <cite index="18-1">SFTP, rsync and symlinks require at least a Premium-level plan, while editing httpd.conf and setting up vhosts is VPS-only.</cite> Without SSH you are doing everything through File Manager, which is workable but slow. Premium or above is the realistic floor.
Step 1 — Set the PHP version and extensions
In hPanel: Websites → Dashboard → PHP Configuration. <cite index="5-1">Select the PHP version and click Update; the change applies within a couple of minutes, and versions below 8.2 are no longer offered.</cite> Pick 8.3 or newer.
Then open the PHP extensions tab. <cite index="9-1">Built-in extensions for your PHP version are listed at the top and cannot be disabled; installed extensions are listed below and can be toggled.</cite> Confirm the Part 0 list is enabled — pay particular attention to zip and pdo_sqlite, which are the two most likely to be off.
<cite index="1-1">allow_url_fopen is enabled by default on Web and Cloud plans.</cite>
Important: <cite index="1-1">direct access to php.ini is disabled on Web and Cloud plans; settings such as memory_limit, error_reporting and upload limits are changed through the PHP Options section instead.</cite> So your error_log path from Part 1 will not work here — use PHP Options → error reporting and hPanel's error log viewer to capture the [ResourceQueryBuilder] lines.
Step 2 — Create the database(s)
Databases → Management. Enter a name, username and password. <cite index="25-1">The hostname for all Hostinger databases is localhost.</cite>
Record the full prefixed values — u123456789_sepodesk, u123456789_sepo — those are what go in
.env, not the short names you typed.
Step 3 — Migrate your data
On the XAMPP box:
mysqldump -u root -p --no-create-db --skip-triggers sepodesk_auth > auth.sql
mysqldump -u root -p --no-create-db --skip-triggers sp_db_apps > apps.sql
Then open the dump and delete any CREATE DATABASE, USE, GRANT or DEFINER= lines that
survived. Import through phpMyAdmin from the Databases screen.
If you are consolidating into one database (Step 0), import both dumps into the same target and
watch for table-name collisions — the tsp_sys_* prefix should keep auth tables clear of app tables.
Step 4 — Upload the files
With SSH (Premium+):
# on your machine
zip -r sepodesk.zip . -x "*.git*" "storage/cache/*" "*.log"
scp -P 65002 sepodesk.zip u123456789@your-server-ip:~/domains/yourdomain.com/public_html/
# on the server
cd ~/domains/yourdomain.com/public_html && unzip sepodesk.zip && rm sepodesk.zip
Without SSH: upload the ZIP through File Manager and extract it there. <cite index="8-1">When extracting a ZIP in File Manager, entering a single dot . as the extract location places the files in public_html.</cite>
Step 5 — Deal with the document root
Hostinger serves each site from public_html, and on shared hosting you cannot simply repoint the
document root a level up. That means your content/, storage/ and .env sit inside the web root
and are directly fetchable over HTTP unless you block them.
This is not theoretical for SepoDesk. These files are all plain JSON or Markdown in
content/:
content/lms/enrollments/*.enroll.json— student names and emailscontent/lms/quiz_results/— per-user quiz answers and scorescontent/.../form_submissions/*.json— every form response you have ever collectedcontent/memberships/*.json— tier, payment method, contribution totals
Under your current XAMPP setup, sitting in wp-content/plugins/sepodesk/, those are already
web-reachable today. Fix it as part of this move. Use the .htaccess in Appendix A —
LiteSpeed (what Hostinger runs) honours it, and <cite index="18-1">mod_rewrite works on Web and Cloud plans with no extra setup.</cite>
Verify by visiting https://yourdomain.com/.env and https://yourdomain.com/content/lms/ in a
browser. Both must 403 or 404. Do not skip this check.
Step 6 — Production .env
AUTH_DB_DRIVER=mysql
AUTH_DB_HOST=localhost
AUTH_DB_NAME=u123456789_sepodesk
AUTH_DB_USER=u123456789_sepo
AUTH_DB_PASS=<the password you set in hPanel>
AES_KEY=<copied verbatim from the XAMPP .env>
WORDPRESS_MODE=false
TRUSTED_HOSTS=yourdomain.com,www.yourdomain.com
TRUST_PROXY=true
COOKIE_SAMESITE=Lax
COOKIE_SECURE=true
SESSION_TIMEOUT=7200
INSTALLER_KEY=<new long random string>
UPDATER_ALLOW_INSECURE=false
TRUST_PROXY=true because LiteSpeed sits in front of PHP; without it RouterSettings can resolve
http for scheme and your assets will be blocked as mixed content once SSL is on.
Step 7 — Clear the caches from the old box
rm -f storage/cache/*.json
RoleDbAccessCache and ApiKeyRoleCache persist resolved role-to-database targets as JSON. If you
copied them across, they still point at your XAMPP database names and the connector will resolve to
databases that do not exist here.
Step 8 — Permissions
Shared hosting runs PHP as your user, so there is no www-data ownership step. Directories at
755, files at 644:
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
chmod 600 .env
Step 9 — Enable SSL, then run the installer
Turn on the free SSL certificate in hPanel and force HTTPS before you first log in — otherwise
your session cookie is issued over plain HTTP while COOKIE_SECURE=true, and the login will appear
to succeed and then immediately bounce you back to the login screen.
Then run the installer endpoints with the new INSTALLER_KEY, and rebuild the front-end bundles so
sepo-core.min.js and sepo-bundle.min.css match the deployed source.
Step 10 — Post-deploy checklist
Run the Part 1, Step 9 smoke test against the live domain, plus:
https://yourdomain.com/.envreturns 403/404https://yourdomain.com/content/lms/returns 403/404- A guest can submit a public form and vote in a public poll
- The
/api/v2/uploadroute accepts a file (checkupload_max_filesizein PHP Options) - Rotate
INSTALLER_KEYonce the install is verified
Part 3 — Hostinger VPS
Take the VPS route if you want to keep the multi-database architecture, need CREATE DATABASE from
InstallerService, or want the cross-database joins in enrollments.php and transcript.php to
work untouched.
You get root, so it is the Part 1 Option B stack on a public IP:
sudo apt update && sudo apt install nginx mariadb-server \
php8.3-fpm php8.3-mysql php8.3-sqlite3 php8.3-mbstring php8.3-zip php8.3-curl
sudo mysql_secure_installation
sudo certbot --nginx -d yourdomain.com # free SSL
Then: Appendix B for the Nginx config, Part 1 Steps 5–9 for the rest. Two differences from shared
hosting worth planning for — you own the backups (set a cron for mysqldump plus the content/
tree, since half your data lives in files, not the database), and you own security updates.
Put the application above the document root on a VPS. It removes the entire class of problem from Part 2, Step 5:
/var/www/sepodesk/ <- project root, not served
/var/www/sepodesk/public/ <- document root: index.php + assets only
Appendix A — .htaccess for Apache / LiteSpeed
<IfModule mod_rewrite.c>
RewriteEngine On
# Serve real files and directories directly
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Everything else goes to the front controller
RewriteRule ^ index.php [L]
</IfModule>
# Block the file-backed stores, env and logs
RedirectMatch 404 ^/(content|storage|Storage|migration|vendor)/
RedirectMatch 404 /\.env
RedirectMatch 404 /\.git
<FilesMatch "\.(env|json|md|log|sql|ini)$">
Require all denied
</FilesMatch>
# Re-allow the JSON/Markdown that must stay public
<FilesMatch "^(manifest|schema)\.json$">
Require all granted
</FilesMatch>
Options -Indexes
Test after applying. If a partial stops rendering, the
FilesMatchblock is too broad — narrow it to thecontent/path rather than loosening it globally.
Appendix B — Nginx + PHP-FPM
server {
listen 80;
server_name sepodesk.local;
root /var/www/sepodesk;
index index.php;
client_max_body_size 32M; # FileUploadBoard
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 120;
}
# file-backed stores, env, VCS
location ~ ^/(content|storage|Storage|migration)/ { deny all; return 404; }
location ~ /\.(env|git) { deny all; return 404; }
# long cache on built bundles (they are cache-busted with ?v=)
location ~* \.(js|css|woff2?|png|svg|jpg)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
Appendix C — Migration gotchas, ranked
Description list rather than a table — your Markdown renderer breaks on pipe characters inside table cells, and several of these values contain them.
The AES key
: CryptoAES::decrypt() returns its input unchanged when the key is wrong instead of throwing.
Symptom: Access denied for user '...'@'localhost' even though the credentials in hPanel are
correct. Cause: the encrypted role password decrypted to garbage. Fix: copy AES_KEY verbatim
from the old .env.
Missing env keys
: SpEnvLoader::env() throws rather than defaulting. Symptom: blank 500 on first request, nothing
in the app log. Fix: grep for env( and populate every key.
Stale role cache
: storage/cache/*.json holds resolved database targets from the old machine. Symptom: connector
resolves to a database that does not exist. Fix: delete the cache files after migrating.
Windows backslash slugs
: FilePageStore slugs carry \ on Windows and / on Linux. You already hit this with the polls
index. Anything that compares or prefixes a slug must str_replace('\\', '/', ...) first, or
match on directory instead. Moving to Linux hosting may hide the bug locally while it stays
broken for anyone still developing on Windows.
Case-sensitive paths
: Linux distinguishes Storage/Backups from storage/backups. Your codebase uses both casings for
different things. Verify each one resolves after the move.
PHP 8.3 strictness : Implicit nullable parameters and dynamic property creation are deprecated. Watch the log on first boot for deprecations coming out of the older classes.
The build script
: It requires wp-load.php. In standalone mode that file is absent and the bundler fatals before
it writes sepo-core.min.js. Guard the include before your first production build.
Appendix D — Pre-launch checklist
[ ] php -m lists every required extension
[ ] SELECT VERSION() >= 10.4.3 (MariaDB) or 5.7.8 (MySQL)
[ ] Every env key referenced in code exists in .env
[ ] AES_KEY copied verbatim from the old environment
[ ] storage/cache cleared of the old machine's JSON
[ ] storage, Storage, migration, content are writable
[ ] /.env and /content/ return 403 or 404 over HTTP
[ ] SSL active and HTTPS forced before first login
[ ] COOKIE_SECURE=true, UPDATER_ALLOW_INSECURE=false
[ ] TRUSTED_HOSTS lists the real domain; TRUST_PROXY=true behind LiteSpeed
[ ] Front-end bundles rebuilt against deployed source
[ ] Guest can submit a form and vote in a poll (PUBLIC_ROUTES intact)
[ ] Scoped resource loads (SCOPE + JSON functions working)
[ ] INSTALLER_KEY rotated after install
[ ] Backup covers both the database and the content/ tree