Skip to content

Installing on a VPS

For aaPanel, CyberPanel, or a plain Ubuntu server. Two to three hours.

This page assumes you are comfortable with a server. If you are not, shared hosting does almost everything a VPS does — see Which hosting do I need?.

Only do this if you need it

A VPS is required for face recognition attendance and IP camera attendance, and lets you run Laravel Octane for extra speed. Everything else — including biometric attendance machines and every school module — runs on shared hosting with far less work.

If you are here because you have many schools, you probably do not need a VPS.

What you are building

Step 1 — Create the site and database

In aaPanel: Website → Add Site, entering your domain and ticking the option to create a database at the same time. In CyberPanel: Websites → Create Website, then Databases → Create Database.

Install PHP 8.3 or 8.4 (not 8.5), MySQL 8.0+ or MariaDB 10.5+, and Nginx.

Note the database name, user and password.

Step 2 — Point the site at the public folder

This step is required on a VPS

Unlike shared hosting, Nginx ignores the .htaccess file we ship. If you skip this, you will get a blank page or a file download instead of the site.

In aaPanel: site Settings → Site Directory, set Running Directory to /public and save. Then open Pseudo-static and choose the Laravel template.

In CyberPanel, set the document root to the public folder in the vHost configuration.

Step 3 — Upload the files and import the database

  1. Upload the .zip into your site folder — typically /www/wwwroot/yourdomain.com on aaPanel — and extract it
  2. Delete any default index.html or 404.html the panel created
  3. Open phpMyAdmin, select your database, use Import, and import the supplied .sql file

You should now see the system's tables listed in phpMyAdmin.

Step 4 — Create .env

Copy .env.install.example to .env and fill in APP_URL and the three DB_ values. Full details are in the shared hosting guide — the file is identical.

Not .env.example

.env.example is the developer file. It turns on debug mode and sends email to a log file. Use .env.install.example.

Step 5 — Set file permissions

Run this once as root over SSH, replacing the path and the web user (www on aaPanel, often www-data on plain Ubuntu):

bash
SITE=/www/wwwroot/yourdomain.com
chown -R www:www $SITE
find $SITE -type d -exec chmod 755 {} +
find $SITE -type f -exec chmod 644 {} +
chmod -R 775 $SITE/storage $SITE/bootstrap/cache
chmod 600 $SITE/.env
chmod 755 $SITE/artisan

Never use 777

It is the most common "fix" on the internet and it makes your files writable by every account on the server. The permissions above are the only ones this system needs.

Run any later artisan commands as the web user, not as root — for example sudo -u www php artisan config:cache. Running them as root creates files the web user cannot then overwrite, which produces confusing failures days later.

Step 6 — The private media rule (critical)

This is the step people miss, and the failure is baffling when it happens: logos and ordinary images work perfectly, but student documents, generated ID cards and certificates return "not found".

Private files live in storage/app/private and are deliberately not reachable as static files. They are served through the application via signed /media/private/… links, so permission can be checked on every request. Default caching rules in aaPanel, CyberPanel and most Nginx setups grab anything ending in .png or .pdf and return a 404 before the application ever runs.

The fix is to let those URLs through first.

Nginx — this must be the first location block. The ^~ makes it win over the caching rules below it:

nginx
# 1. CRITICAL: private media must route through Laravel — never served as static files
location ^~ /media/private/ {
    try_files $uri $uri/ /index.php?$query_string;
}

# 2. Main Laravel routing
location / {
    try_files $uri $uri/ /index.php?$query_string;
}

# 3. Static caching MUST come after the exclusion above
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
    expires 30d;
    access_log off;
}

If you run Laravel Octane, proxy that block to the Octane port instead:

nginx
location ^~ /media/private/ {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Apache — put this at the top of public/.htaccess, before every other rule:

apacheconf
RewriteEngine On

# CRITICAL: force private media through Laravel (before all other rules)
RewriteCond %{REQUEST_URI} ^/media/private/
RewriteRule ^ index.php [L]

Then reload:

bash
sudo nginx -t && sudo nginx -s reload     # Nginx
sudo systemctl restart apache2            # Apache

Check it worked

Go to Sidebar → Server & Health → Server Health → Maintenance and press Test private-media routing.

What you seeMeaning
A styled error page from the systemCorrect — requests are reaching the application
A plain web-server 404 pageWrong — your rule is missing, or sitting below the caching rules

The difference matters: a styled error means the application answered. A bare 404 means the web server swallowed the request.

Step 7 — Cron, SSL and first login

  1. Add the cron job — see The cron job. Copy the command from your own panel rather than typing it
  2. Issue an SSL certificate for the domain in your panel, and confirm APP_URL in .env starts with https://
  3. Open your domain, log in, and change the password immediately
  4. Go to Server & Health → Server Health and clear anything it reports
  5. Replace your application key

If something went wrong

What you seeWhyFix
Blank page, or the browser downloads a fileSite root is not set to publicStep 2
Logos load, but certificates and student documents 404Private media rule missing or in the wrong orderStep 6
"Permission denied" errors in the logWrong ownership, or artisan was run as rootRe-run step 5
502 Bad GatewayPHP-FPM is not running, or the site points at the wrong PHP versionRestart PHP-FPM; confirm PHP 8.3 or 8.4
Nothing sends automaticallyCron job missingThe cron job