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 eachartisanline 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 instorage/,vendor/orbootstrap/cache/can’t be written by the site afterwards, which ends in500errors. - 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
- Back up (section 3)
- Patch and set up PHP (section 4)
- Get the code and install the packages (section 5)
- Update
.env(sections 6 and 7) - Run the migrations (section 8)
- Clear the caches and restart PHP (section 9)
- nginx directives (section 10)
- Scheduler (section 11)
- Reverb (section 12)
- Storage inspector (section 13)
- Frontend (section 14)
- 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:
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.
The first commands fail
The first commands fail
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
Write down the running version
~ is the subscription’s home (/var/www/vhosts/<backend-domain>), outside the public web folder.Copy the .env
Dump the database
-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).Optional: archive the local files
4. Patch PHP and check its settings
4.1 The version
Since 25/10/2026composer.json accepts only PHP builds that fix CVE-2026-109618 / CVE-2026-109619 (“PHP2Shell”, fixed upstream on 21/10/2026):
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:
- Plesk → Tools & Settings → Updates: update Plesk and its PHP components (
/opt/plesk/php/<X.Y>is Plesk’s own package). - 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. - Restart that PHP-FPM: Plesk → Tools & Settings → Services Management, or as root
systemctl restart plesk-php<XY>-fpm(plesk-php84-fpmfor 8.4).
$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
4.3 The limits
Plesk → the backend domain → PHP Settings:client_max_body_size, section 10).
5. Get the code and install the packages
Optional: put the API in maintenance mode
503 instead of half-working.php artisan up.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.Delete the Laravel 10 cache files
composer install, artisan cannot start to clear them itself, and Composer’s last step runs artisan. Laravel rebuilds them by itself.Install the packages
composer command often runs with another PHP version and refuses the lock file:/usr/lib/plesk-9.0/composer.phar on this server? Download Composer into the home folder once: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).New server only: the storage link and the folder rights
https://<backend-domain>/) shows it as OK.Without SSH: run artisan from Scheduled Tasks
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
TELESCOPE_* line once Telescope is removed (Monitoring & Logs).
6.2 Add
Add this block at the end. If the file already has aBROADCAST_DRIVER line, delete it and keep the one in this block (section 6.4 explains why).
<generated> values come from section 7.
6.3 Check the values that already exist
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:
# 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
7.2 The values you generate yourself
They are random strings, so any machine can make them. The first tab needs onlyopenssl: 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.
- With openssl (computer or server)
- With PHP (server)
.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.
The PHP command fails on the server
The PHP command fails on the server
-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
.env: section 9 (clear the caches, restart PHP), and $PHP artisan reverb:restart once Reverb runs.
8. Run the migrations
migrate changes the laptop’s database. A server deployed on 09/10/2026 runs these (the first one only if it is still pending):
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
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: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/storagelink (php artisan storage:link, section 5). /storage-inspectoris not affected: it does not start with/storage/(no slash afterstorage), 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:
127.0.0.1:8080 and never cross nginx, so they need no block.
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:url of a training cover uploaded to local storage.
11. The scheduler (cron)
Plesk → Scheduled Tasks → look for an existingschedule: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.Try Reverb by hand
.env still says log):private-chatroom.0. Stop it with Ctrl+C. Never use --debug under supervisor (it prints every frame).Keep it running (root, once)
- supervisor
- systemd
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.Check the nginx block
/app/ curl of section 10.4: it must answer 101 Switching Protocols.Switch the app to live
.env:POST /api/auth/login):GET /api/realtime/config when a conversation opens.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>/) andGET /api/local_storage_stats. R2 files:GET /api/r2_storage_statsand the R2 file endpoints (File Management & Storage).
13.2 Open it
The page answers404 to anyone else. Three ways in:
13.3 What each row shows
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).
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:
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.Prepare your computer (once)
.github/deployment/.env.deploy (git-ignored) with the SSH accounts of the frontend domains:sshpass installed (brew install sshpass) the upload uses the password without asking; otherwise SSH asks for it.Point the build at the right backend
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:Environment.test publishes the PROD site talking to the TEST backend.Build and upload
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.Then put constants.dart back to TEST so day-to-day work doesn’t call production:The frontend domain's nginx: certificate links
https://<frontend-domain>/certificate/ID/...). Plesk → the frontend domain → Apache & nginx Settings → Additional nginx directives:^~wins over the Flutter fallback toindex.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 onsends that name during the TLS handshake so the certificate matches.- No path after the domain in
proxy_pass: the full URL is forwarded unchanged.
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.Phone apps
constants.dart change, and publish them: an app built before 19/10/2026 does not understand the new login payload either.index.html removes the old service worker, so a visitor gets the new version after one reload.
15. Check everything
On the server:- 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: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.main with git checkout main for the next attempt.
19. Troubleshooting
storage/logs/laravel.log (the API), the supervisor stdout_logfile (Reverb), and Plesk → the domain → Logs (nginx and Apache).