Skip to main content
This page takes a backend and a frontend hosted on Plesk from the version deployed on 09/10/2026 to the current one, with every command. It also works on a brand-new server: the steps that differ are marked new server. Deploy TEST first, check it (section 15), then do PROD the same way.
No real value appears on this page. Every key, secret, password and token below is a placeholder (<generated>, <token>, <db user>, …). Generate your own values, keep them in that server’s .env only, and never commit a .env file. If a real .env was ever pasted into a chat, a ticket or a screenshot, rotate the keys it holds (section 7).

1. What changed since 09/10/2026


2. Before you start

2.1 Values per environment

2.2 What you need

  • SSH as the subscription’s system user (Plesk → Websites & Domains → the domain → Hosting settings shows it). The SSH shell must be /bin/bash, not /bin/bash (chrooted): a chrooted shell cannot see Plesk’s PHP. Without SSH, run each artisan line through Plesk → Scheduled Tasks → Run a command → Run Now (section 5.6). Logged in as root instead? Switch to that user first (section 2.4): files root creates in storage/, vendor/ or bootstrap/cache/ can’t be written by the site afterwards, which ends in 500 errors.
  • Root once, to install supervisor for Reverb (section 12).
  • A Plesk administrator, if PHP has to be updated (section 4).
  • About 30 minutes per environment, plus the frontend build (≈10 minutes on your computer).
  • On your computer: Flutter 3.44 stable and the frontend repository (section 14).

2.3 The order

  1. Back up (section 3)
  2. Patch and set up PHP (section 4)
  3. Get the code and install the packages (section 5)
  4. Update .env (sections 6 and 7)
  5. Run the migrations (section 8)
  6. Clear the caches and restart PHP (section 9)
  7. nginx directives (section 10)
  8. Scheduler (section 11)
  9. Reverb (section 12)
  10. Storage inspector (section 13)
  11. Frontend (section 14)
  12. Checks (section 15)

2.4 Start every SSH session like this

Every command below runs on the server, in the backend folder, in the same SSH session, as the subscription’s system user. 1. Logged in as root? (whoami prints root, the prompt ends with #.) Switch to the user that owns the site, on a line of its own:
Skip this step when you logged in as the subscription’s user. Root is only needed for supervisor (section 12) and systemctl: exit takes you back to root. 2. Open the backend folder and pick Plesk’s PHP:
<X.Y> is the branch shown in Plesk → the domain → PHP Settings (section 2.1). ls /opt/plesk/php/ lists the branches installed on the server, and the .php-version file next to httpdocs, when there is one, holds the domain’s. Use the same branch everywhere this page shows <X.Y> (Scheduled Tasks, cron, supervisor), and plesk-php<XY>-fpm without the dot (plesk-php84-fpm for 8.4). PHP=… only lives in the current shell: a new SSH session, or a new su, forgets it. Run step 2 again.
Already ran git, composer or artisan as root? As root, list the files root owns in the site, then give them back to the site’s user:

3. Back up

1

Write down the running version

The rollback (section 18) goes back to this commit. ~ is the subscription’s home (/var/www/vhosts/<backend-domain>), outside the public web folder.
2

Copy the .env

3

Dump the database

The production database still holds ≈6.6 GB of old Laravel Telescope tables (see Monitoring & Logs). Leave them out, or the dump takes minutes and gigabytes for nothing:
-p asks for the DB_PASSWORD of .env. --no-tablespaces avoids the “PROCESS privilege” error a Plesk database user gets on MySQL 8. Without SSH: Plesk → Databases → the database → Export Dump (drop the Telescope tables first, or that dump includes them).
4

Optional: archive the local files

Nothing in this deploy moves or deletes uploaded files. The archive is only for peace of mind.

4. Patch PHP and check its settings

4.1 The version

Since 25/10/2026 composer.json accepts only PHP builds that fix CVE-2026-109618 / CVE-2026-109619 (“PHP2Shell”, fixed upstream on 21/10/2026): On an older build, composer install refuses to run and the site answers 500 "Composer detected issues in your platform". Check both the command line and the PHP that really serves the domain:
If either one is older:
  1. Plesk → Tools & Settings → Updates: update Plesk and its PHP components (/opt/plesk/php/<X.Y> is Plesk’s own package).
  2. Plesk → Websites & Domains → the backend domain → PHP Settings shows the handler the domain uses. An “OS vendor” handler is updated with apt / dnf, not by Plesk.
  3. Restart that PHP-FPM: Plesk → Tools & Settings → Services Management, or as root systemctl restart plesk-php<XY>-fpm (plesk-php84-fpm for 8.4).
The domain and $PHP must run the same branch. A domain left on an older PHP fails on the new code while the command line works.

4.2 The extensions

A missing extension: Plesk → Tools & Settings → PHP Settings → the PHP version → enable it.

4.3 The limits

Plesk → the backend domain → PHP Settings: If the server already has higher values, keep them. nginx needs the same upload size (client_max_body_size, section 10).

5. Get the code and install the packages

1

Optional: put the API in maintenance mode

Recommended on PROD: this deploy upgrades Laravel and runs migrations, so for a few minutes the API answers 503 instead of half-working.
The apps show a connection error until section 9 runs php artisan up.
2

Pull main

git status prints files? Someone edited the code on the server. Look at the change (git diff), keep a copy if it matters, then git stash before pulling.Plesk Git instead of SSH: Websites & Domains → the backend domain → Git → Pull Updates. Then do the next steps over SSH or through Scheduled Tasks.
3

Delete the Laravel 10 cache files

These files list the Laravel 10 packages (Snappy and the others that are gone). Between the pull and composer install, artisan cannot start to clear them itself, and Composer’s last step runs artisan. Laravel rebuilds them by itself.
4

Install the packages

Run Composer with Plesk’s PHP. The plain composer command often runs with another PHP version and refuses the lock file:
No /usr/lib/plesk-9.0/composer.phar on this server? Download Composer into the home folder once:
Or Plesk → the backend domain → PHP Composer → Install (it uses the domain’s PHP).composer install also removes laravel-snappy from vendor/. An error naming php ~8.2.35 || ~8.3.36 … means the PHP is not patched (section 4.1).
5

New server only: the storage link and the folder rights

An existing server already has the link: the status page (https://<backend-domain>/) shows it as OK.
6

Without SSH: run artisan from Scheduled Tasks

Plesk → Scheduled Tasks → Add Task → Run a command, paste the line, Run Now, read the output, then delete the task. If your Plesk has the Laravel Toolkit, its Artisan tab does the same thing.

6. Update the .env

Edit it over SSH (nano .env) or in Plesk → Files → the backend folder → .env → Edit in Text Editor.

6.1 Delete

Nothing reads them anymore (PDFs are rendered by dompdf). Also delete any TELESCOPE_* line once Telescope is removed (Monitoring & Logs).

6.2 Add

Add this block at the end. If the file already has a BROADCAST_DRIVER line, delete it and keep the one in this block (section 6.4 explains why).
The <generated> values come from section 7.

6.3 Check the values that already exist

The project keeps its Laravel 10 configuration files, so the key names stay BROADCAST_DRIVER and CACHE_DRIVER. Don’t rename them to Laravel 11’s BROADCAST_CONNECTION / CACHE_STORE: those names are not read.

6.4 One line per key

Laravel reads .env from top to bottom and the last line wins: BROADCAST_DRIVER=reverb near the top and BROADCAST_DRIVER=log further down runs with log. List the keys that appear twice:
A value with a space, a # or a $ goes between double quotes (BACKEND_VERSION="#40b6709"): an unquoted # starts a comment.

7. Generate the .env secrets

7.1 Every secret at a glance

Never generate a new APP_KEY on a server that already runs. It encrypts the platform settings stored encrypted in the database, the signed links (storage inspector, e-mail and impersonation links already sent) and the sessions. With a new key, every encrypted setting fails to decrypt (500). Generate it only on a new server, once. If it ever leaked, plan the rotation: re-enter the encrypted settings after the change.

7.2 The values you generate yourself

They are random strings, so any machine can make them. The first tab needs only openssl: it runs the same on your computer (macOS or Linux terminal) and in the server’s SSH shell, with no PHP and no $PHP. The second tab needs the session set up as in section 2.4.
Paste each printed line into .env, replacing the empty or placeholder line. Don’t save the output anywhere else, and don’t paste it into a chat or a ticket. Only lowercase letters and digits come out of these commands, which is what Reverb and .env need.
-r: command not found: $PHP is empty in this shell, so bash takes -r for the command. Run the cd and PHP= lines at the top of the tab first, in the same shell. Any other error: the table in section 2.4. Meanwhile, the first tab gives the same kind of values without PHP.

7.3 The values a dashboard gives you

After any change to .env: section 9 (clear the caches, restart PHP), and $PHP artisan reverb:restart once Reverb runs.

8. Run the migrations

Run them on the server: run from a laptop, migrate changes the laptop’s database. A server deployed on 09/10/2026 runs these (the first one only if it is still pending): Before the category migration, see which items will lose categories (phpMyAdmin → SQL):
The rows it deletes are saved in storage/app/backups/category-pivots/<date-time>.json (nothing is written when every item already has one category). Keep that file: it is the only way to put a category back by hand. The storage inspector lists it as orphan; don’t delete it there. Rollback, if ever needed: $PHP artisan migrate:rollback --step=N undoes the last N migrations, but the category rows only come back from the backup file, and rolling back the chatroom tables deletes their messages. Prefer the database dump of section 3.

9. Clear the caches and restart PHP

Don’t run php artisan optimize or php artisan config:cache on this backend. Several controllers read .env directly with env(): the Konnect keys, FRONTEND_URL, the mail sender, the R2 custom domain, BACKEND_VERSION. Once the configuration is cached, Laravel stops loading .env and those calls return null. Konnect payments then fail, and password-reset links lose their domain. This was checked on Laravel 12.70 on 09/11/2026. Cache the routes and the views only, until those calls move into config/ files.
Then restart PHP so OPcache drops the Laravel 10 files: Plesk → the backend domain → PHP Settings → Apply (saving restarts the handler), or Tools & Settings → Services Management → restart PHP-FPM, or as root systemctl restart plesk-php<XY>-fpm (plesk-php84-fpm for 8.4). Do this after every pull and every .env change. Leave maintenance mode (if section 5 turned it on):

10. nginx: the backend’s additional directives

Plesk → Websites & Domains → the backend domain → Apache & nginx Settings → Additional nginx directives. That box is one field for the whole site (HTTP and HTTPS). Add only what is missing; the full box reads:
Press OK: Plesk checks the syntax and reloads nginx. If it refuses, the message names the line. The two location blocks don’t conflict: each one handles its own path.

10.1 client_max_body_size

nginx refuses a request body bigger than this with 413 Request Entity Too Large, before PHP sees it. Writing the line replaces Plesk’s default for the site, so give it the same value as post_max_size (section 4.3). 25m is enough for chat images but would break the JSON imports and any upload above 25 MB.

10.2 The /storage/ block: images on the web app

Why it is needed. The Flutter web app draws with CanvasKit. To show a network image it downloads the image’s bytes with a cross-origin request and uploads them to the GPU, and the browser allows that only when the image’s answer carries an Access-Control-Allow-Origin header. On Plesk, nginx answers /storage/... images straight from the disk (“Serve static files directly by nginx”), so the request never reaches Apache or Laravel and Laravel’s CORS settings never run on it. Without the block: images stored on the server’s disk (training covers, logos, avatars, landing-page images uploaded to local storage) show as broken tiles on the web app only. They open fine in a browser tab, and the browser console says No 'Access-Control-Allow-Origin' header and EncodingError: The source image cannot be decoded. R2 images are not affected (the R2 bucket has its own CORS rule in Cloudflare), and neither are the phone apps (no CORS outside browsers).
  • * is right here: images are fetched without cookies, and these are the same files anyone can already open by URL. Keep the header inside /storage/: the API answers with the caller’s origin and allows credentials, and a global * would break it.
  • It needs the public/storage link (php artisan storage:link, section 5).
  • /storage-inspector is not affected: it does not start with /storage/ (no slash after storage), so it still reaches Laravel.

10.3 The /app/ block: live chat messages

Why it is needed. Live chat uses Laravel Reverb, a WebSocket server that runs as its own process next to Laravel and listens on 127.0.0.1:8080, which only the server itself can reach. The apps can’t reach that port, and they need an encrypted wss:// connection on the site’s existing certificate. So they open:
/app/<key> is the fixed path every Pusher-protocol client opens. This block catches it and forwards it to Reverb:
No new domain, no new certificate and no open port. Laravel’s own event posts go straight to 127.0.0.1:8080 and never cross nginx, so they need no block. Without the block the chat still works, just not live: wss://…/app/… reaches Laravel and gets a 404, the app falls back to polling every 5 seconds, and messages arrive with that delay. Live mode needs all three: BROADCAST_DRIVER=reverb, the Reverb process kept running (section 12), and this block. Keep port 8080 closed in the firewall: only nginx and Laravel, on the same machine, use it.

10.4 Check both blocks

From your computer:
A local file name to test with: any image from the storage inspector (section 13), or the url of a training cover uploaded to local storage.

11. The scheduler (cron)

Plesk → Scheduled Tasks → look for an existing schedule:run task first. If there is none: Add Task → Run a command, every minute (* * * * *), as the site’s system user:
chatrooms:prune-attachments deletes, every night, chat images that were uploaded but never sent (older than 24 hours). Without the cron they pile up in R2.

12. Laravel Reverb: live chat messages

Without this section the chat works by polling every 5 seconds. With it, a message reaches the other side in milliseconds. No Redis and no queue worker are needed (one Reverb process holds thousands of connections). Full details: Chatrooms Setup Guide.
1

Try Reverb by hand

In a second SSH shell, send a test event (the prefix forces Reverb while .env still says log):
The first shell logs a block for private-chatroom.0. Stop it with Ctrl+C. Never use --debug under supervisor (it prints every frame).
2

Keep it running (root, once)

Plesk’s Scheduled Tasks cannot keep a process alive. Pick one:
Run Reverb as the site’s system user: reverb:restart signals the process through the file cache, which only works for the same user. One program per environment; two environments on one server get two ports (8080, 8081), used in their REVERB_SERVER_PORT, REVERB_PORT and nginx block.
3

Check the nginx block

Run the /app/ curl of section 10.4: it must answer 101 Switching Protocols.
4

Switch the app to live

In .env:
From your computer, with the token of a test account (from POST /api/auth/login):
Open the same chatroom in two browsers (a learner and the instructor): a message shows up on the other side at once. The apps need no rebuild: they read GET /api/realtime/config when a conversation opens.
Back to polling at any time: BROADCAST_DRIVER=log, then section 9. The apps poll again within 5 seconds; Reverb can keep running.

13. The local storage inspector (/storage-inspector)

A read-only web page on the backend that answers one question: what is stored on the server’s disk, and who uses it? It lists every file under storage/app (the “local” disk, not Cloudflare R2), finds the database rows that point at each file, and labels it. It never deletes, moves or changes anything.

13.1 Why use it

  • Before cleaning the disk: see which files nothing uses anymore, and which are still referenced.
  • When an image is broken in the app: find the file’s row and status (missing = the file is gone from the disk).
  • To know what a file is for: which training, exam, user, company or landing page uses it, with ids and slugs.
  • Disk usage numbers live elsewhere: the status page (https://<backend-domain>/) and GET /api/local_storage_stats. R2 files: GET /api/r2_storage_stats and the R2 file endpoints (File Management & Storage).

13.2 Open it

The page answers 404 to anyone else. Three ways in: The route allows 30 requests per minute per address and tells search engines not to index it. Paging and filter links keep the token or signature. Treat the token like a password: whoever has it sees every file name and who uses it. To change it, write a new value and run section 9 again.

13.3 What each row shows

It finds references through the foreign keys to files.id (training images, videos and badges, quiz and question images, user avatars, company and certificate logos, landing-page images, theme logo), the URL columns (theme logo, auth page image, default badge, partner logos), the rich text that embeds /storage/... links (lessons, classrooms, training and quiz descriptions, question labels, landing page, public profiles), and two naming rules (certificate-images/<id>-<fingerprint>.png, the cached certificate share images, rebuilt on demand; public/files/2fa-<user>-<time>.png, the 2FA QR codes).
Trust it in one direction only. A file marked used is used. A file marked orphan could still be used by something the page cannot see: a URL hard-coded in the frontend, a lesson link without /storage/, or a backup such as backups/category-pivots/*.json (section 8). Look at it before deleting, and keep the backup of section 3.

13.4 Filters

Add them to the URL (the page’s form does it for you): q (path, URL, model, slug, label or #id), status, area (folder), sort (modified, size, name, status, area), dir (asc, desc), per_page (10 to 200, default 50), page. Example:
The disk scan and the database index are cached for STORAGE_INSPECTOR_CACHE_SECONDS (default 60). The rescan now link (refresh=1) rebuilds them at once.

14. Deploy the frontend (Flutter web)

Deploy the frontend right after the backend, in the same session. Since 19/10/2026 the backend sends the slim login payload by default, and a web build from before that date no longer understands it.
1

Prepare your computer (once)

Create .github/deployment/.env.deploy (git-ignored) with the SSH accounts of the frontend domains:
With sshpass installed (brew install sshpass) the upload uses the password without asking; otherwise SSH asks for it.
2

Point the build at the right backend

The API address is fixed at build time in lib/core/constants/constants.dart. The script’s test / dev / prod argument only chooses which server receives the files, not which backend the app calls. For PROD:
and comment out the TEST pair. A PROD deploy with Environment.test publishes the PROD site talking to the TEST backend.
3

Build and upload

The script runs flutter clean, pub get, both translation generators, build_runner, flutter build web --release --pwa-strategy=none (CanvasKit; prod adds the commit hash), trims the font manifest, then mirrors build/web/ into the domain’s httpdocs/ with rsync --delete.
The upload is a 1:1 mirror: any file in the frontend’s httpdocs/ that is not in the new build is deleted. Check the environment name before pressing Enter, and keep nothing else in that folder.
Then put constants.dart back to TEST so day-to-day work doesn’t call production:
4

The frontend domain's nginx: certificate links

Public certificate pages are rendered by the backend but shared on the app’s domain (https://<frontend-domain>/certificate/ID/...). Plesk → the frontend domain → Apache & nginx Settings → Additional nginx directives:
  • ^~ wins over the Flutter fallback to index.html, so /certificate/... never reaches the web app.
  • Host <backend-domain> is required: Plesk picks the backend site by host name. proxy_ssl_server_name on sends that name during the TLS handshake so the certificate matches.
  • No path after the domain in proxy_pass: the full URL is forwarded unchanged.
Then, on the backend, CERTIFICATE_PUBLIC_URL=https://<frontend-domain> and section 9. Without the proxy, leave that key empty: the links stay on the backend domain and still work.
5

Phone apps

Build the Android and iOS apps from the same commit, with the same constants.dart change, and publish them: an app built before 19/10/2026 does not understand the new login payload either.
The web app is no longer a PWA (since 19/10/2026): the new index.html removes the old service worker, so a visitor gets the new version after one reload.

15. Check everything

On the server:
From your computer:
In the apps:
  • The status page https://<backend-domain>/ is green (database, storage link, R2, mail, Turnstile).
  • Log in as a student: the dashboard opens, training covers show (web too), a lesson and a quiz open.
  • Log in as an admin: download the dashboard PDF (dompdf), open the platform settings.
  • Create a classroom, create its chatroom, send a message from the instructor: the learner sees it (at once with Reverb, within 5 s without).
  • Send a chat image of a few MB: no 413.
  • Open a certificate link and the verify page on the frontend domain.
  • On TEST: start a Konnect payment from the cart; it must open Konnect’s payment page (a cancelled payment comes back to the app as failed).
  • “Forgot password” sends an e-mail whose link opens the frontend domain.

16. Optional clean-ups


17. Every later deploy

Once a server has gone through this page, a normal deploy is:
Then the frontend (section 14, steps 2 and 3) when it changed.

18. Roll back

The fastest safe rollback is the previous code with the database dump of section 3. It loses what users did after the deploy, so decide quickly.
Then redeploy the previous frontend build (check out its commit and run section 14). Go back to main with git checkout main for the next attempt.

19. Troubleshooting

Where to look: storage/logs/laravel.log (the API), the supervisor stdout_logfile (Reverb), and Plesk → the domain → Logs (nginx and Apache).