# Putting Thinkera on a server (Ubuntu + Apache)

Two applications, each listening only on the loopback, with Apache in front of both:

| Address | Apache sends it to | What it is |
|---|---|---|
| `https://thinkera.academy` | `127.0.0.1:8890` | the public site (Next.js + Payload CMS) |
| `https://platform.thinkera.academy` | `127.0.0.1:8891` | the learning platform (Moodle) |

Nothing else is exposed: both `docker-compose.yml` files bind to `127.0.0.1`, so the ports cannot be reached
from outside even before Apache is configured.

## 1. Apache

Four files, two per domain: the port-80 one goes first (certbot proves the domain over plain HTTP, and cannot do
that if Apache refuses to start), and the HTTPS one only after the certificate exists.

```bash
sudo apt install apache2 certbot
sudo a2enmod proxy proxy_http headers rewrite ssl http2
sudo mkdir -p /var/www/certbot
sudo cp deploy/apache/*.conf /etc/apache2/sites-available/

# 1. HTTP only — this must start cleanly with no certificate anywhere
sudo a2ensite thinkera.academy platform.thinkera.academy
sudo apache2ctl configtest && sudo systemctl reload apache2

# 2. the certificates, proved through the path those vhosts keep open
sudo certbot certonly --webroot -w /var/www/certbot -d thinkera.academy -d www.thinkera.academy
sudo certbot certonly --webroot -w /var/www/certbot -d platform.thinkera.academy

# 3. now HTTPS
sudo a2ensite thinkera.academy-ssl platform.thinkera.academy-ssl
sudo apache2ctl configtest && sudo systemctl reload apache2
```

**Renewal.** `certbot.timer` renews by itself, but Apache has to be told to pick up the new file:

```bash
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh >/dev/null <<'SH'
#!/bin/sh
systemctl reload apache2
SH
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh
sudo certbot renew --dry-run
```

> `certbot --apache` (rather than `certonly`) would write its own HTTPS vhost, which would then fight with ours
> over the same ServerName. Use `certonly`, as above.

## 2. What each application must be told

**Moodle refuses to work properly behind a proxy unless it knows.** In `platform/moodle/.env`:

```
MOODLE_WWWROOT=https://platform.thinkera.academy
MOODLE_SSLPROXY=1
MOODLE_REVERSEPROXY=
DEV_LOGIN_KEY=
```

`MOODLE_REVERSEPROXY` must be **empty** here, and this is the one that bites: Apache passes the original Host
(`ProxyPreserveHost On`), and Moodle throws *«Reverse proxy enabled so the server cannot be accessed directly»*
whenever that switch is on and the Host it receives is its own `wwwroot` host. The switch is for a proxy that
sends a different, internal name. Locally it is `1` for another reason — the published port (8891) is not the
port inside the container — which is why the two environments differ.

`MOODLE_SSLPROXY=1` is what tells Moodle that TLS ends at Apache; `wwwroot` must then be an `https://` address.

`DEV_LOGIN_KEY` **must be empty on any server that faces the internet** — with a value, anyone who learns it
signs in as a demo student. Empty, the page answers 404.

```bash
cd platform/moodle
docker compose up -d                 # the containers read .env when they start
docker compose exec -u www-data moodle php admin/cli/purge_caches.php
```

`config.php` reads the environment at run time, so this needs no rebuild.

In `thinkera.academy/.env`:

```
SITE_URL=https://thinkera.academy
PLATFORM_URL=https://platform.thinkera.academy
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=info@thinkera.academy
SMTP_PASS=…
SMTP_FROM=info@thinkera.academy
NOTIFY_EMAIL=info@thinkera.academy
```

```bash
cd thinkera.academy
docker compose up -d                 # page metadata, canonical links and sitemap use SITE_URL
```

An empty `SMTP_HOST` keeps the site silent: every message is written to the container log instead of sent,
which is what a machine without the mailbox password should do. `docker compose restart` does **not** re-read
`.env` — use `docker compose up -d` (it recreates the container) after changing any of these.

### Where the academy's notices land

A new enrolment request on the platform, and a new interest request on the site, are both mailed to one
inbox. `NOTIFY_EMAIL` in each `.env` sets it, and either side can be changed afterwards without a deploy:

| | Changed later at |
|---|---|
| Platform | Site administration → Plugins → Local plugins → Thinkera Academy |
| Site | `/admin` → إعدادات الموقع → بريد إشعارات الطلبات |

On the platform the box is filled from `.env` by `set_mail.php`, so re-running that script puts the `.env`
value back. On the site the CMS field wins whenever it is filled, and `NOTIFY_EMAIL` is the fallback.

### Live sessions

Live sessions run on a BigBlueButton server; the platform only needs its address and secret, in
`platform/moodle/.env` as `BBBURL` and `BBBSECRET`. Every other choice (students wait for the instructor,
muted on joining, recorded by default, how early the room opens, room size, inside the platform or in a new
tab) is a `BBB_*` line in the same file; `.env.example` lists them with their defaults. After a change:

```
docker compose up -d --force-recreate moodle cron
docker compose exec -T -u www-data moodle php public/local/thinkera/cli/set_bbb.php --test
```

`--test` asks the server for its version and whether it accepts the secret. `import-platform.sh` runs the
same script, so a fresh server picks the settings up by itself.

The room opens inside the platform in a frame, so the conference server must allow being framed and must
let the browser use the camera and microphone from within it (hosted BigBlueButton does). If a provider
refuses framing, set `BBB_EMBED=0`: the room then opens in the same tab and the platform takes the student back to the session page when they leave. The production server runs this way since 21 September 2026, because the hosted server adds its own "join from the app or the browser" page that does not work inside a frame.

### WhatsApp notifications

WhatsApp is a third channel beside Web and Email. It reaches students through the WhatsApp gateway (`POST /notify`
with an `X-API-Key` header), at the student's mobile number from the account's second phone field. Its settings are six
`WHATSAPP_*` lines in `platform/moodle/.env` (see `.env.example`):

| Line | Meaning |
|---|---|
| `WHATSAPP_ENABLED` | `1` turns the channel on, `0` turns it off for everyone. It stays off while there is no key. |
| `WHATSAPP_GATEWAY_URL` | the gateway as the platform container sees it: `http://gateway:8000` when the gateway joins the platform's Docker network, otherwise `http://host.docker.internal:8882` or the gateway's public address |
| `WHATSAPP_API_KEY` | the gateway key issued for the platform |
| `WHATSAPP_PHONE_FIELD` | `phone2` (the mobile on Thinkera accounts) |
| `WHATSAPP_COUNTRY_CODE` | `20`: replaces the leading 0 of local numbers (010… becomes 2010…) |
| `WHATSAPP_EVENT` | the `event` value sent with every message (`manual`) |

After a change, apply it and optionally send yourself a test message:

```bash
docker compose exec -T -u www-data moodle php /var/www/html/public/local/thinkera/cli/set_whatsapp.php
docker compose exec -T -u www-data moodle php /var/www/html/public/local/thinkera/cli/set_whatsapp.php --to=01xxxxxxxxx
```

The admin then chooses, per notification, which channels it uses and whether they are on by default:
Site administration > Messaging > Notification settings, where WhatsApp appears as its own column. Students can
turn it off per notification in their preferences. Messages are queued and sent by cron within a minute; when the
gateway is down they are retried later, and a number the gateway refuses is not retried.

### The catalogue lives on the platform

Which courses exist, what they are called, what they cover and where they stand are set **on each course on
the platform**, and the site prints them. It reads `GET /local/thinkera/feed.php` — the whole catalogue with
prices, in both languages — and keeps the answer five minutes.

**Where each thing is set**

| On the site it is | Set on the platform at |
| --- | --- |
| the Arabic title | the course's full name |
| the Arabic description | the course's summary |
| everything else on the course page | course settings → the three groups «الموقع», «صفحة الموقع — العربية», «Site page — English» |
| the category and the order | Site administration → Courses → Manage courses and categories (drag to reorder) |
| the price and any offer | the course's «الدفع عبر InstaPay» enrolment method (below) |

**A new course** is made on the platform, in a category, and appears on the site the moment it has a
«المعرّف في رابط الموقع» — the slug. Without one it stays off the site, however complete it is, so a course
can be built in private. It can stay hidden from students on the platform and still be listed on the site,
marked «قريباً», which is how the four courses that have no lessons yet are set up.

**What stays in the CMS** is only how the site shows a course: its picture and caption, the card colour, and
the free-lesson videos on its page. Everything else there is a read-only copy marked «يُدار من المنصة»; the
site uses that copy only if the platform cannot be reached, so the pages never go blank for want of it. To give
a new platform course a picture, add a row in the CMS with the same slug.

The units field takes one unit a line — `number | lessons | Arabic name | English name` — where the lessons can
be written `7/3` (seven, three of them from the book), `24 labs` or `8 projects`.

`local/thinkera/cli/setup_catalogue.php` made the fields, the category and the four empty courses, and copied
each course's details across from the CMS once. It has already run, and its result travels in the platform's
dump; it does not need to run on the server.

### The price is set in one place

The platform is where a student actually pays, so it is the one place a price is set. The site reads it from
the catalogue feed above and prints the same figures, which is what keeps a visitor from being quoted
one price and charged another.

**Set it here:** the course → Participants → Enrolment methods → «الدفع عبر InstaPay»:

| Field | Meaning |
| --- | --- |
| السعر | the normal price — what is charged once any offer ends |
| سعر العرض | what is charged until the day below. Empty means no offer |
| العرض ساري حتى | the last day of the offer, which is itself inside it |

The offer ends by itself: the normal price is always the stored one, so the morning after that day both the
platform and the site are back to it with nobody touching anything.

The site holds the platform's answer for **five minutes**, so a change takes up to that long to appear on
`thinkera.academy`. If the platform cannot be reached at all, the site prints the figures in its own CMS
instead of failing — so those fields are worth keeping roughly right even though nothing reads them normally.

`PLATFORM_INTERNAL_URL` in `thinkera.academy/.env` is the address the site asks on; empty means `PLATFORM_URL`,
which is correct on the server. Check it answers:

```bash
curl -s https://platform.thinkera.academy/local/thinkera/feed.php
```

## 3. Moving the content

A freshly deployed platform has an empty database, so Moodle shows its installer — the code travelled, the
content did not. The same goes for the site: its seed files only build a brand-new CMS, and everything edited
since (the Arabic rewrite, the free-lessons section, the prices) lives in its database.

**On the machine where everything works** (read-only, nothing is changed):

```bash
cd platform/moodle    && sh ../../deploy/export-platform.sh
cd ../../thinkera.academy && sh ../deploy/export-site.sh
```

That writes `deploy/_export/<date>/`: `moodle-db.dump` (2.3 MB), `moodledata.tgz` (4.2 MB, now that the uploaded
lesson videos have gone and the lessons play from YouTube), `site-db.dump` (160 KB), `site-media.tgz` (5.9 MB),
and one `SHA256SUMS` covering all four — each export rewrites only its own two lines, so the order does not matter.

**On the server**, first bring the code across (`git pull`, or copy the working folder) — the theme, the plugins
and the site's own pages are code and never travel in a dump. Then copy the export folder over (`scp -r`),
check it arrived whole, and:

```bash
cd ~/thinkera/deploy/_export/2026-09-20 && sha256sum -c SHA256SUMS
cd ~/thinkera/platform/moodle    && sh ../../deploy/import-platform.sh ~/thinkera/deploy/_export/2026-09-20
cd ~/thinkera/thinkera.academy   && sh ../deploy/import-site.sh       ~/thinkera/deploy/_export/2026-09-20
```

`import-platform.sh` asks for confirmation, replaces the database and moodledata, puts the plugins in place,
runs Moodle's own upgrade step,
rewrites every address inside the content (`http://localhost:8891` → the server's `wwwroot`, with Moodle's
`tool_replace`), re-applies the settings that come from `.env` (SMTP, analytics, the interface strings) and
purges the caches. Set `MIGRATE_OLD_WWWROOT` in `.env` first if the old address was not `http://localhost:8891`.

**The administrator's password does not travel with `.env`** — it is in the database, so after the import you
sign in with the password used on the machine you exported from.

## 4. The links between the two

They are content, not configuration, and they still point at the development machine. In the CMS
(`https://thinkera.academy/admin`):

| Where | Now | Should be |
|---|---|---|
| Settings → «رابط المنصة» (`platform_url`) | `http://localhost:8891` | `https://platform.thinkera.academy` |
| Courses → بكالوريا لغات → «رابط الدورة على المنصة» | `http://localhost:8891/enrol/index.php?id=2` | `https://platform.thinkera.academy/enrol/index.php?id=2` |

## 5. Google sign-in

Google rejects a sign-in whose return address it does not know. In Google Cloud Console → Credentials → the
OAuth client, add:

```
https://platform.thinkera.academy/admin/oauth2callback.php
```

and, under Authorised JavaScript origins, `https://platform.thinkera.academy`. The localhost entry can stay for
development.

## 6. Checks worth running once

```bash
curl -I https://thinkera.academy                      # 200, and HSTS present
curl -I https://platform.thinkera.academy/login/index.php
curl -sI https://platform.thinkera.academy | grep -i location   # no redirect back to localhost
docker compose -f platform/moodle/docker-compose.yml exec -u www-data moodle \
  php public/local/thinkera/cli/test_mail.php --to=you@example.com
```

In a browser: sign in, open a lesson (the YouTube player should appear), start a practice set, and check that
the message that arrives carries the logo — on a real domain the mail client can reach it, which it could not on
localhost.

**The weekly study reminder needs the cron container running.** It is a scheduled task that looks once an hour
for students whose chosen day and hour has just begun:

```bash
docker compose -f platform/moodle/docker-compose.yml logs --tail=5 cron
```

«Moodle upgrade pending, cron execution suspended» means that container is still running older code than the
database: `docker compose exec cron sync-plugins --live` puts it right. `import-platform.sh` does this for both
containers, and it is the reason nothing scheduled runs after an upgrade that touched only the web container.

**Two things that go wrong most often**

- A student signs in and lands back on the sign-in page: `MOODLE_SSLPROXY` is not `1`, so Moodle sends a cookie
  marked "https only" over what it believes is http.
- A page loads but its images and styles do not: `MOODLE_WWWROOT` still has the old address. Moodle builds every
  asset address from it.

## 7. The nightly backup

`deploy/backup.sh` writes both applications into one dated folder and removes the folders older than the last
fourteen. It reuses the export scripts, so a backup folder and a folder prepared for a move are the same
thing — anything it writes can be restored with `import-platform.sh` and `import-site.sh`.

Install it once, as the user who owns the project (`crontab -e`):

```cron
0 2 * * * cd /var/www/private/thinkera.academy && BACKUP_DIR=/var/www/private/thinkera.academy/backups /bin/sh deploy/backup.sh >> /var/log/thinkera-backup.log 2>&1
```

That runs every night at 02:00. Before adding it, create the folder and prove the script once by hand:

```bash
sudo mkdir -p /var/www/private/thinkera.academy/backups && sudo chown "$USER" /var/www/private/thinkera.academy/backups
BACKUP_DIR=/var/www/private/thinkera.academy/backups sh deploy/backup.sh
```

| | |
| --- | --- |
| `BACKUP_DIR` | where the dated folders go. Default: `deploy/_backup` |
| `BACKUP_KEEP` | how many to keep. Default: 14 |

Each run writes to `<date>.partial` and renames it only after `sha256sum -c` passes, so a run cut short by a
reboot never leaves a half-written folder that looks complete, and a folder that is there is a folder that
restores. A failed run removes its own `.partial` and says why in the log.

**A backup on the same machine is not a backup.** Copy the folder somewhere else as well — the simplest is a
second line in the same crontab, after the first has had time to finish:

```cron
30 2 * * * rsync -a --delete /var/www/private/thinkera.academy/backups/ user@elsewhere:/backups/thinkera/
```

**Check it every so often**: `ls -lh /var/www/private/thinkera.academy/backups` and `tail /var/log/thinkera-backup.log`. A restore
into a throwaway database is worth doing once a term, and is the only way to know the backup works.
