{brand:short_name} is one Laravel app answering on several names. On cPanel it lives in one folder, ~/atrium, set up
as the sub-domain api.tutelora.com (the console and the products' API). id.tutelora.com (where people sign in, and where every
e-mail's link goes) points at the same folder. The app works out from the name which part of it answers.
Work top to bottom. Tick each step as you finish it: the ticks stay in this browser, so you can stop and come back.
Everything typed into a terminal happens over SSH on the cPanel account unless a block says on your Mac.
Where a command says <you>, put the cPanel account's user name.
00Before day one
Three things decide whether the account can run {brand:short_name} at all. Ask the host before paying for anything else.
The host must give ask first
- PostgreSQL 15 or later, with the
pg_trgmextension made in the database (a cPanel user can't make it: the host does, once). - PHP 8.4 or later (8.5 preferred) with
pdo_pgsql,intl,sodium,mbstring,zip,dom,gd. - SSH access (Terminal in cPanel will do), Git and Composer.
- Cron jobs, every minute.
The Architect brings yours
- The cPanel account and its server's IP address.
- Access to
tutelora.com's DNS. - Admin on the GitHub repository
tutelora/atrium(to add a deploy key). - A password manager entry ready for the secrets made on the way.
- An SMTP mailer for {brand:company}'s own e-mail (sign-in links, invitations).
Not on this account later
- No virus scanner (ClamAV), so it runs as staging: uploads aren't scanned, and the readiness check says so.
- No Redis or supervisor: the database queue, worked by the scheduler.
- Those come with the VPS, the production home ({brand:short_name}'s plan AF, A8).
app:check fails), and cPanel has none.
So this account runs APP_ENV=staging on the real names: everything works, uploads go unscanned, and payments stay on Paystack's test keys. Moving to the VPS
changes the server, not the names: the products and the people never see the move.01The account and its names
Check PHP and PostgreSQL
In cPanel, MultiPHP Manager sets the PHP version per domain; set 8.5 (or 8.4) once the domains exist (step 3). In the terminal, find the binary cron will use and check its extensions:
ls /usr/local/bin/ea-php8* # e.g. /usr/local/bin/ea-php85
/usr/local/bin/ea-php85 -v
/usr/local/bin/ea-php85 -m | grep -Ei 'pdo_pgsql|intl|sodium|mbstring|zip|dom|gd'Every one of the seven must print. A missing one is a ticket to the host (or Select PHP Version → Extensions on CloudLinux).
Make the database
cPanel → PostgreSQL Databases: create a database atriumdb and a user atrium with a long generated password, then add the
user to the database with all privileges. cPanel prefixes both with the account's name (<you>_atriumdb, <you>_atrium): use the
prefixed names everywhere below.
Then ask the host, in a ticket, to run this as a superuser and to confirm the server's version:
\c <you>_atriumdb
CREATE EXTENSION IF NOT EXISTS pg_trgm;
SELECT version(); -- 15 or laterpg_trgm, can't run {brand:short_name}: a migration needs
PostgreSQL 15's SET NULL (column), and search needs trigrams. Nothing on cPanel stands in for either.Make the two names, both on one folder
cPanel → Domains → Create A New Domain:
- api.tutelora.com, untick "Share document root", document root
atrium/public. - id.tutelora.com, untick "Share document root", document root the same
atrium/public.
The folder doesn't exist yet; cPanel makes it, and step 5 fills it. Set both to PHP 8.5 in MultiPHP Manager.
Point the DNS, then the certificates
Where tutelora.com's DNS lives, add two records to the account's IP address (cPanel shows it as Shared IP Address on its home page):
| Name | Type | Value |
|---|---|---|
| api | A | the account's IP |
| id | A | the account's IP |
If the zone is in this cPanel's Zone Editor, step 3 already made them. Once both names resolve (dig +short api.tutelora.com), cPanel →
SSL/TLS Status → tick both → Run AutoSSL. Both must show a valid certificate: the session cookie is __Host-, which browsers keep
only over https.
02The code
Give the account a deploy key, and clone
The repository is private. Make a key on the account that can only read it:
ssh-keygen -t ed25519 -N '' -C 'atrium@cpanel' -f ~/.ssh/atrium_deploy cat ~/.ssh/atrium_deploy.pub
On GitHub, tutelora/atrium → Settings → Deploy keys → Add deploy key: paste it, title "cPanel", leave write access off.
Then tell SSH to use it, and clone develop into the folder cPanel made (empty it first):
cat >> ~/.ssh/config <<'EOF' Host github-atrium HostName github.com User git IdentityFile ~/.ssh/atrium_deploy IdentitiesOnly yes EOF chmod 600 ~/.ssh/config rm -rf ~/atrium && git clone -b develop git@github-atrium:tutelora/atrium.git ~/atrium
Install what the app needs
cd ~/atrium /usr/local/bin/ea-php85 $(which composer) install --no-dev --optimize-autoloader --no-interaction chmod -R ug+rwX storage bootstrap/cache
If which composer finds nothing, cPanel keeps it at /opt/cpanel/composer/bin/composer.
The pages' assets are built by Node. If the account has it (node -v, 20 or later), build there. If not, build on your Mac and upload:
npm ci --no-audit --no-fund && npm run build
cd ~/dev/Tutelora/tuteatrium && git checkout develop && git pull npm ci && npm run build rsync -az --delete public/build/ <you>@<the account's host>:atrium/public/build/
03The settings and the secrets
Make the secrets
Each prints once. Copy each into the password manager as it appears, then into .env (step 8). Never into git, chat or a ticket.
cd ~/atrium cp deploy/production.env.example .env && chmod 600 .env /usr/local/bin/ea-php85 artisan key:generate --show # APP_KEY /usr/local/bin/ea-php85 artisan vault:key # FILE_VAULT_KEY /usr/local/bin/ea-php85 artisan backup:key # BACKUP_PUBLIC_KEY here; its private half: the password manager only /usr/local/bin/ea-php85 -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' # VOUCHER_KEY: set once, never changed /usr/local/bin/ea-php85 artisan passport:keys # sign-in keys for the products, written under storage/
FILE_VAULT_KEY opens every uploaded file, and VOUCHER_KEY finds every
voucher code. Lose either and those are gone. The sign-in keys can be replaced: products fetch the new public key on their own.Fill in .env
Open ~/atrium/.env (cPanel's File Manager editor, or nano). It holds the production example; change these lines to the values for this
account, and fill every <…> left:
APP_ENV=staging APP_DEBUG=false APP_URL=https://id.tutelora.com BRAND_APP_HOST=id.tutelora.com HOST_ID=id.tutelora.com HOST_API=api.tutelora.com HOST_INK=tutel.ink # answers once tutel.ink's DNS points here HOST_FILES= # empty until a files host has its DNS APP_KEY=<from step 7> DB_DATABASE=<you>_atriumdb DB_USERNAME=<you>_atrium DB_PASSWORD=<the database user's password> QUEUE_CONNECTION=database # no Redis here QUEUE_VIA_SCHEDULER=true # the scheduler works the queue each minute FILE_VAULT_SCANNER=fake # no ClamAV here: uploads aren't scanned FILE_VAULT_KEY=<from step 7> BACKUP_PATH=/home/<you>/backups # outside the app's folder BACKUP_PUBLIC_KEY=<from step 7> BACKUP_REMOTE= # empty: backups stay on the account until rclone is set VOUCHER_KEY=<from step 7> MAIL_MAILER=smtp MAIL_HOST=<the mailer's SMTP host> MAIL_USERNAME=<its user> MAIL_PASSWORD=<its password> MAIL_FROM_ADDRESS=<the sender, e.g. no-reply@tutelora.com> TRUSTED_PROXIES= # empty, unless the host says a proxy sits in front: then its address CSP_MODE=report # a week of reports first, then enforce SCHEDULE_HEARTBEAT_URL= # empty unless an uptime check gives one
Leave REDIS_*, the SES lines, PASSPORT_* (the key files under storage/ serve), Google, Microsoft and Turnstile as they are
until each is set up: the app runs without them and says so. SERVICE_IDENTITY is written for you in step 10.
04The first run
Run the deploy steps
One command does what every release does, in order, and stops at the first that fails: the migrations, the Input Fields lists, the roles, the e-mails and their checks, the mail log's partitions, the brand's check, and the caches.
cd ~/atrium && mkdir -p ~/backups /usr/local/bin/ea-php85 artisan app:deploy
Each step prints a ✓ and its time, then "Deployed: N steps". A failure names its step and stops; fix it and run the command again: each step is safe to repeat.
Make the Service, the platform's own account
/usr/local/bin/ea-php85 artisan service:create <the Service's e-mail> --first="<first name>" --last="<family name>"
/usr/local/bin/ea-php85 artisan optimize # service:create wrote SERVICE_IDENTITY into .env: cache it againIt shows a generated password once: into the password manager. Then sign in at https://api.tutelora.com/login and set up two-factor at once.
The Service can't be deleted or have its e-mail changed from the web; service:whoami says who it is.
Start the clock: one cron line
cPanel → Cron Jobs → Common settings "Once Per Minute" → command:
cd /home/<you>/atrium && /usr/local/bin/ea-php85 artisan schedule:run >> /dev/null 2>&1
It runs everything timed: the queue (e-mails, webhooks to products, background work), the checks of the products' servers, backups, purges and the readiness record. Without it nothing is sent.
Check it's ready
/usr/local/bin/ea-php85 artisan app:check
/usr/local/bin/ea-php85 artisan backup:run # the first backup, into ~/backups, encryptedExpect passes, and these, which are right for this account: the scanner off (fake), the page policy reporting, no remote for backups, and the sign-in providers not set. Any fail must be fixed before anyone else signs in. The console's Operations page shows the same lines.
See it answer
| Open | Expect |
|---|---|
| https://api.tutelora.com/up | 200, Laravel's health page: it answers only when the database and the queue do |
| https://id.tutelora.com | {brand:company}'s sign-in page |
| https://api.tutelora.com/console | the console's dashboard, signed in as the Service, with {brand:short_name} at its centre |
| https://api.tutelora.com/v1/ping | 401 as JSON, "Not signed in": the API answers, and refuses a call without a key |
| Any other name on the account | 404, on purpose |
05Every release after
cPanel has no zero-downtime deployer, so a release updates the folder in place and takes a few seconds of maintenance. Only develop is deployed.
cd ~/atrium P=/usr/local/bin/ea-php85 $P artisan down --retry=30 # "back in a moment" for anyone who calls meanwhile git pull --ff-only origin develop $P $(which composer) install --no-dev --optimize-autoloader --no-interaction # the assets: npm ci && npm run build here, or rsync public/build from your Mac (step 6) $P artisan backup:run --kind=predeploy # the database as it was, before this release $P artisan app:deploy $P artisan queue:restart $P artisan up $P artisan app:check
If app:deploy stops on a step, the site stays in maintenance: fix the cause, run it again, then up. A release that must be undone goes
back to the previous commit (git log --oneline -5, git checkout <hash>, then the same steps). Migrations only go forward: undoing one
that changed data means restoring the predeploy backup (backup:list, backup:restore <name>; reji/docs/BACKUP.md).
06When something's wrong
| You see | Look at | Usually |
|---|---|---|
| 500 on every page | storage/logs/app.json-<date>.log, the last lines | A missing .env value, the wrong PHP for the domain, or storage/ not writable |
| 404 on a name that should work | HOST_ID, HOST_API in .env, then artisan optimize | A name spelt differently from the one the browser uses |
| Signed in, then signed out again at once | The certificate; whether the host puts a proxy in front (then its address in TRUSTED_PROXIES) | The __Host- cookie needs https, and the app must see it |
| No e-mail arrives | The cron line; app:check's mail line; failed_jobs | The scheduler isn't running, or the SMTP login is wrong |
| "419" or a form that silently resets | SESSION_DOMAIN | It must stay empty: each name keeps its own session |
A migration fails on SET NULL | SELECT version(); | PostgreSQL older than 15: step 2's stop |
| Search finds nothing | \dx in the database | pg_trgm wasn't made |
A change to .env does nothing | — | The config is cached: artisan optimize after every .env change |
07The weeks after
| When | What | Where it's written |
|---|---|---|
| After a week of use | Read the csp.violation lines, fix any that are {brand:short_name}'s own, then CSP_MODE=enforce | reji/docs/DEPLOYMENT.md |
| Before a text is sent | The company's Sailup key on the console (Messages → Channels), and a sender name | reji/docs/reference/notify.md |
| Before a product sends e-mail | SES: each product's notify.<domain> verified, its configuration set, the SNS topic in NOTIFY_SES_TOPICS | reji/docs/DEPLOYMENT.md |
| Before a short link is sent | tutel.ink's DNS and certificate on the same folder | reji/docs/reference/links.md |
| When the keys come | Turnstile, Google, Microsoft; Paystack's test keys and its webhook https://api.tutelora.com/hook/paystack | reji/docs/DEPLOYMENT.md, step 6 |
| The first product | Its server on the console, server:sign-in <srv_…>, then Legatus in the product, from its own folder | reji/tasks/other-apps.md, packages/legatus/README.md |
| The VPS | Redis, Supervisor and ClamAV: APP_ENV=production, the Redis queue, the real scanner | reji/docs/DEPLOYMENT.md, "A new server" |
The Markdown guide (reji/docs/DEPLOYMENT.md) stays the full reference, VPS included; this manual is the cPanel path through it, step by step.
A step that changes in the code changes here in the same commit.