Skip to content
Article Setting up SepoDesk without XAMPP
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

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 through PdoFactory
  • pdo_sqlite — the dual-driver paths in InstallerService and uploadSchema
  • opensslCryptoAES (encrypted role DB passwords) and TLS on remote updates
  • mbstring — Markdown parsing, front matter, safeHtml
  • json — every file-backed store (PollStore, FormSubmissionStore, MembershipStore, …)
  • zipSystemUpdater unpacking update packages
  • fileinfoFileUploadBoard upload validation
  • curl or allow_url_fopen — fetching remote updates from tsp_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 requires wp-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:

  1. AES_KEY is 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 bewildering Access denied for user from MySQL instead of a crypto error. Copy the key across with the database.
  2. UPDATER_ALLOW_INSECURE=true is a local-only flag so the updater will accept http://. It must be false in 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

  1. / loads the SPA shell and sepo-core.min.js returns 200 (not 404 — that means assets_url is wrong)
  2. Log in — exercises AuthSessionController, session fingerprinting, the refresh-token cookie
  3. /admin/dashboard — exercises Registry, resource definitions, ResourceQueryBuilder
  4. Open a scoped resource (users) — confirms the SCOPE block and the JSON functions work
  5. /docs — confirms mountFiles, the partials, and the store wiring
  6. Submit a poll or form as a guest — confirms the PUBLIC_ROUTES whitelist survived the move
  7. Upload a file — confirms fileinfo and 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_NAME to 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 emails
  • content/lms/quiz_results/ — per-user quiz answers and scores
  • content/.../form_submissions/*.json — every form response you have ever collected
  • content/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/.env returns 403/404
  • https://yourdomain.com/content/lms/ returns 403/404
  • A guest can submit a public form and vote in a public poll
  • The /api/v2/upload route accepts a file (check upload_max_filesize in PHP Options)
  • Rotate INSTALLER_KEY once 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 FilesMatch block is too broad — narrow it to the content/ 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