From an empty virtual machine to a student sitting their first interview. About an hour of work, most of it spent waiting for packages to install and for DNS to propagate.
Every command on this page was run, in this order, against a machine that started empty. The outputs shown are real. Where something went wrong the first time, this guide says so rather than pretending the path was clean.
Five things, and the platform can produce none of them for you. Get them before you start — the provisioning script stops and tells you what is missing, but it is a duller hour if you collect them as you go.
Debian 12 or Ubuntu 22.04 and newer, 2 GB of memory, root access over SSH with a key. Any provider. Nothing here is specific to one.
A domain or subdomain you control the DNS for. rise.your-school.example is
perfectly good; it does not need its own domain.
Any SMTP server. Prefer the school's own: an invitation asking a student to set a password should arrive from an address they already recognise.
Scaleway's Generative APIs by default — the model runs in Paris, so no student answer leaves the EU. Billed per token, not per seat.
Optional. Only if teachers will author interviews in Google Sheets. Content can be imported from CSV instead.
Running costs are the server, the domain, and whatever the model provider charges — which is per token used, not per student or per seat, so it scales with how much the platform is actually used. Rather than quote rates that go stale, note that the platform records the token count and the computed cost of every single model call: after a week of real use you can read your own number off your own installation.
Do this first and do it before anything else, because the provisioning script asks a certificate authority to verify the name — and that check fails, irreversibly for an hour or so, if the name does not yet resolve to this machine.
At your DNS provider, one record:
| Type | A |
|---|---|
| Name | rise — or @ if you are using a whole domain |
| Value | the server's IPv4 address |
| TTL | whatever the default is |
Then wait, and check from somewhere that is not your own laptop — your laptop may have cached the answer from before the record existed:
dig +short rise.your-school.example
When that prints your server's address, carry on. If it prints nothing after twenty minutes, the record is wrong somewhere; no amount of waiting fixes a typo.
One script takes the machine from nothing to a running installation: system packages, an unprivileged deploy user, PostgreSQL, the application under systemd, nginx, a TLS certificate that renews itself, log rotation, and a daily database backup.
Fetch it and read it before you run it. It runs as root, it is some six hundred lines, and every section says what it is about to do and why.
# on the server, as root
curl -fsSLO https://raw.githubusercontent.com/tehnickazr/RISE-Project/main/deploy/provision-production.sh
less provision-production.sh
The script's built-in defaults are ours — app.riseproject.space, and our
repository. Left alone it will cheerfully request a certificate for a name you do not own and fail.
Pass RISE_DOMAIN.
RISE_DOMAIN=rise.your-school.example \
RISE_ADMIN_EMAIL=admin@your-school.example \
bash provision-production.sh
Four environment variables change its behaviour. The rest is fixed on purpose, so that two installations look the same when something needs debugging:
| RISE_DOMAIN | The hostname the platform answers on. Always set this. |
|---|---|
| RISE_ADMIN_EMAIL | Where the certificate authority sends expiry warnings, and the default from-address for mail. |
| RISE_REPO | A different clone URL — your own fork, for instance. |
| RISE_BRANCH | A branch other than main. |
It takes about eight minutes, nearly all of it apt. It prints every step, and it says
what it skipped as clearly as what it did — because it is designed to be run again. Run it a
second time after fixing something and it will not overwrite the database, the environment file, or
the certificate.
It does not invent the secrets you have to obtain, it does not create the first administrator, and it does not switch on automatic deployment. A script that wrote plausible-looking placeholder keys would hand you a server that starts and then fails at the first interview, which is a worse failure than one that refuses to start.
It ends by printing what still needs a human. That list is the rest of this page.
The environment file lives at /opt/apps/rise/shared/.env, owned by the
deploy user and readable by nobody else. It is outside the application directory
deliberately: a deploy replaces the checkout, and the secrets must survive that.
ssh root@rise.your-school.example
nano /opt/apps/rise/shared/.env
Everything that could be generated already has been — the session secret, the database password,
the public origin. Four lines are marked NEEDS VALUE.
In the Scaleway console: Identity and Access Management → API keys → Generate an API key. Name it for this server, so that revoking it later is not an act of archaeology.
The script fills the mail settings with the defaults for the host we used. If your mail is
elsewhere, correct SMTP_HOST, SMTP_PORT and SMTP_USER as well
as the password. Port 587 is STARTTLS, 465 is implicit TLS; the platform infers which from the port.
Port 25 is blocked outbound by nearly every provider and will only ever time out.
This cost us an afternoon. Mail passwords often contain |, &,
$ or #. systemd reads this file literally and does not care — but the
deploy script reads it with source, where an unquoted | is a pipe and the
rest of the password is a command. Write
SMTP_PASS='the-password', in single quotes, always.
Only if teachers will author content in Google Sheets. Create a service account in the Google Cloud console, enable the Sheets API, download a JSON key, and share each school's spreadsheet with the service account's address as Viewer — the platform never writes to a sheet. Leave both blank to start with; content imports from CSV without them.
Then restart, and confirm it came back:
systemctl restart rise
systemctl status rise --no-pager
Do this before mail is an invitation to a fourteen-year-old. The usual failure is not a wrong password but a blocked port, and from your laptop the two look identical — which is why this runs on the server.
cd /opt/apps/rise/current/backend
sudo -u deploy node --env-file=/opt/apps/rise/shared/.env \
scripts/check-mail.mjs you@your-school.example
It prints the configuration it resolved, opens the connection, authenticates, and sends one plain message. Go and look in the inbox. And in the spam folder — if it landed there, fix SPF and DKIM on the sending domain now, because invitations that go to spam are indistinguishable from invitations that were never sent, and you will spend a week not knowing which.
There is no default account and no default password. One is generated here, shown once, and stored only as a hash — which is also why this is a command and not a line in the database schema, where a password would live in version control forever.
cd /opt/apps/rise/current/backend
sudo -u deploy node --env-file=/opt/apps/rise/shared/.env \
scripts/create-super-admin.mjs admin@your-school.example "Platform administrator"
super administrator created
---------------------------
email admin@your-school.example
password Xq4t9Fbn2wKpLmR7vZ
Shown once. Change it after signing in.
Write it down before you close the terminal. There is no second copy. The way back, if you lose it, is a password-reset link to that mailbox — which is one more reason step 4 comes before this one.
It creates schools and invites their administrators. It cannot read a single student's transcript, at any school, ever — not because it is forbidden by a permission check, but because no endpoint exists that would return one. The same is true of the school's own administrator. Only the student, and teachers at that student's own school, can see an interview.
Everything from here happens in a browser. Sign in at
https://rise.your-school.example and change that generated password on the account page
straight away.
Then sign out, open the invitation, and set the password as that school administrator. It is tempting to skip this and stay signed in as the platform administrator — don't. It is the exact path every teacher will take, and it is the one most likely to have a rough edge on your particular mail setup.
An installation with no content is a working installation with nothing to do. As the school's administrator, in Settings:
scenarios, questions, rubrics,
glossary. Start from the sample CSVs in seed/sheets/
— four trades, five questions each — and replace them with your own.Editing the spreadsheet changes nothing until somebody presses import. That is what stops a half-finished edit reaching a class mid-lesson. Imports are versioned, and every interview records the version it was taken against, so changing a question can never alter an interview already sat.
A daily backup is already running. The provisioning script took one while it worked and repeats it
at 02:30, writing to /var/backups/rise with fourteen days kept. That answers somebody
deleted the wrong class on Tuesday, which is much the likeliest way data is lost.
It does not answer the disk died or the account was closed, because the database and every dump are on the same disk. Closing that is this step, and it needs a bucket.
Any S3-compatible object storage works. We use Scaleway's, in Paris, because the data then stays in the same jurisdiction as the platform. The settings that matter:
| Visibility | Private. |
|---|---|
| Versioning | On. This is what makes a deletion recoverable. |
| Object lock | Off — see below. This one is not obvious. |
| Encryption | The provider-managed option (Scaleway calls it SSE-ONE). Redundant, since the backup is already encrypted before it leaves the host, but it needs no configuration and is worth the sentence in your records. Avoid customer-managed keys: they add a second way to lose everything. |
It looks like exactly what a backup wants — write once, delete never. Two reasons not to.
The practical one: restic writes a lock file into the repository on every run and removes it when it finishes, so an immutable bucket eventually blocks its own housekeeping.
The one that actually decides it: a student has the right to erasure. Under a write-once policy you cannot remove their data from the backups even when you are obliged to, and you get to explain that to a supervisory authority. Versioning gives you the recovery you want while leaving you able to delete things.
Versioning means nothing is ever really deleted — a delete lays a marker over the object and the old version survives underneath it. Without expiry rules, "we keep backups for 90 days" quietly becomes "we keep everything forever, including data we told someone we had removed."
| Expire non-current versions | 90 days, retain-count blank. |
|---|---|
| Expire incomplete multipart uploads | 7 days. Abandoned upload parts are billed but invisible in the file listing. |
| Everything else | Nothing. |
"Expire current versions" and "Expire non-current versions" sit next to each other, one word apart, and the console offers to default both to 90 days. Current versions are your live backup data. A rule there deletes the repository from the oldest end, and it fails silently — backups keep running, keep reporting success, and you discover the hole during a restore.
The rule to hold onto: lifecycle rules may only ever touch things restic has already finished with. Non-current versions are yesterday's tombstones; abandoned upload parts are litter. Everything else in that bucket is the backup.
Transitions to a colder tier are also wrong here, tempting as the pricing looks. restic reads the repository index on every run, and an archived object needs a restore request taking hours before it can be read at all — so the first backup after the transition simply fails.
You need an API key with both halves this time — the access key identifies you and the secret signs each request. That differs from the inference key in step 3, where only the secret mattered, and it is an easy hour to lose.
nano /etc/rise/backup.env
RESTIC_REPOSITORY='s3:s3.fr-par.scw.cloud/your-bucket-name'
AWS_ACCESS_KEY_ID='...'
AWS_SECRET_ACCESS_KEY='...'
Change the repository line. The provisioning script writes a guess there, and a guess that points at a bucket you do not own fails on the first unattended run, at half past two, when nobody is watching.
Leave RESTIC_PASSWORD alone — it was generated during provisioning, and changing it
orphans everything encrypted under the old one. Quote all three values: this file is read with
source, the same trap as SMTP_PASS in step 3.
Then create the repository and run it once by hand:
set -a; source /etc/rise/backup.env; set +a
restic init
/usr/local/bin/rise-backup
created restic repository a8b7974a09 at s3:s3.fr-par.scw.cloud/rise-db-backup
rise-backup: local ok 2026-09-15T09:20:05+00:00
Added to the repository: 128.719 KiB (14.969 KiB stored)
snapshot 27b2154f saved
rise-backup: off-site ok 2026-09-15T09:20:13+00:00
That restic init succeeding is also your proof that the credentials work, since it had
to write to the bucket to do it.
RESTIC_PASSWORD is the only thing that makes those backups readable — including by
you. It currently exists in exactly one place: a file on the machine the backups exist to protect.
Put it in a password manager before you close the terminal. This is the single most common way an
off-site backup turns out to have been worthless.
The provisioning script used to advise a key that could write but not delete. That is the right instinct and it does not work: restic removes its own lock file at the end of every run, so a key without delete permission fails immediately. Versioning plus the expiry rule above is how you actually get the property — a delete leaves the real object underneath, recoverable for 90 days.
Check your provider's machine backups are on, in their panel. Nothing running on the host can do this for itself, and it answers the failure a database dump cannot: the machine you restore onto — the OS, the packages, nginx, systemd, all of it. On most providers this is already enabled by default, so the job is usually to confirm it rather than switch it on, and to notice the retention. Hostinger, for example, keeps four images on a rolling basis, so the oldest machine you can go back to is a fortnight old.
Providers offer both and the words get used interchangeably. A snapshot is a manual checkpoint you take by hand, usually limited to one at a time, and on some providers — Hostinger among them — it is deleted automatically after a day.
That makes it the right tool immediately before a risky change: a major Node or PostgreSQL upgrade, anything touching the whole machine. Take it, do the work, confirm the platform still serves, and let it expire. What it must never be is the thing you are relying on the following morning, because by then it is gone.
Check tomorrow's backup, not today's. You just watched today's. The one worth
verifying is the one that ran unattended while you were asleep, and the line to look for is
off-site ok rather than the "not configured" message it printed before the bucket
existed.
systemctl list-timers rise-backup --no-pager
tail /var/log/rise/backup.log
A backup nobody has restored is a hypothesis. This takes about five minutes, it touches nothing live, and it is the only thing that turns a configured backup into a proven one. Do it now, and again after any change to the database or the backup configuration.
Deliberately from the bucket rather than the local dump — the local copy would not exercise the credentials, the encryption, or the download.
set -a; source /etc/rise/backup.env; set +a
restic snapshots
WORK=$(mktemp -d); chmod 700 "$WORK"
restic restore latest --target "$WORK"
find "$WORK" -type f
Two files come back: rise.dump, the database, and env.backup, a copy of
the environment file. That second one is there because it is the one thing on the host that cannot be
reconstructed from anything else — session secret, database password, provider keys.
pg_restore runs as the postgres user, which cannot read into a
0700 directory owned by root. You get Permission denied and it looks like
a broken backup. Hand the file over explicitly instead of loosening the directory — the dump holds
student data and belongs at 0600.
install -o postgres -g postgres -m 600 \
"$(find "$WORK" -name rise.dump)" /var/lib/postgresql/restore-test.dump
sudo -u postgres createdb rise_restoretest
sudo -u postgres pg_restore -d rise_restoretest --no-owner --no-privileges \
/var/lib/postgresql/restore-test.dump
Silence from pg_restore means it worked. Now compare the restored database against
the live one — not just "did it load", but "is the same data in it":
for t in users organizations interview_sessions interview_messages \
answer_feedback answer_criterion_scores content_versions \
org_settings platform_settings audit_log; do
L=$(sudo -u postgres psql -d rise -tAc "SELECT count(*) FROM $t")
R=$(sudo -u postgres psql -d rise_restoretest -tAc "SELECT count(*) FROM $t")
[ "$L" = "$R" ] && M=match || M='*** MISMATCH ***'
printf ' %-26s live %-5s restored %-5s %s\n' "$t" "$L" "$R" "$M"
done
users live 1 restored 1 match
organizations live 1 restored 1 match
interview_sessions live 0 restored 0 match
answer_feedback live 0 restored 0 match
answer_criterion_scores live 0 restored 0 match
content_versions live 0 restored 0 match
org_settings live 0 restored 0 match
platform_settings live 1 restored 1 match
audit_log live 0 restored 0 match
Read a row or two as well, not only the counts. A school name with diacritics in it is a good choice: an encoding fault survives a row count perfectly happily and shows up the moment you look at the text.
You have just written a plaintext copy of every secret on the host into a temporary directory, and a full copy of the database into a second one.
sudo -u postgres dropdb rise_restoretest
shred -u /var/lib/postgresql/restore-test.dump
find "$WORK" -type f -exec shred -u {} \; ; rm -rf "$WORK"
The same restore, aimed at the real database. Which source you use depends on what went wrong:
| Bad delete, host fine | Use the local dump in /var/backups/rise. No credentials, no download, seconds. |
|---|---|
| Host lost entirely | Provision a new machine from step 1, then restore from the bucket. You need RESTIC_PASSWORD, which is why it lives in your password manager. |
Restoring over the live database is one command, and it is destructive, so read it twice:
systemctl stop rise
sudo -u postgres pg_restore -d rise --clean --if-exists --no-owner --no-privileges path/to/rise.dump
systemctl start rise
--clean drops each object before recreating it. Stopping the service first is not
politeness: a live connection writing during a restore produces a database that matches neither the
backup nor what you had.
On a rebuilt host, restore env.backup to
/opt/apps/rise/shared/.env as well — or at minimum lift
SESSION_SECRET from it. A new one invalidates every signed-in session on the platform,
which on a Monday morning means every student and teacher discovering they are logged out at
once.
Once it is running, updating is one command, run deliberately by a person:
ssh deploy@rise.your-school.example \
'bash /opt/apps/rise/current/deploy/deploy-production.sh'
It pulls, installs, builds the frontend, applies any pending database migrations and restarts the service. There is a timer in the repository that would do this every minute, and it is left switched off on a production host on purpose: on a machine holding interview transcripts, what is running should change because somebody decided it should.
| journalctl -u rise -n 100 | Why the service will not start. Start here, always. |
|---|---|
| tail -f /var/log/rise/app.log | What the application is doing while a student uses it. |
| nginx -t | After touching a vhost, before reloading and finding out the hard way. |
| certbot certificates | What is issued and when it expires. Renewal is automatic; verifying it happened is not. |
Three failures account for most of what goes wrong on a first install: a TLS certificate that was never issued because DNS had not propagated when the script ran (fix the record, run the script again — it is safe); mail that authenticates but is silently filed as spam (SPF and DKIM); and a language-model key that was pasted with a trailing newline or a wrapped line (the platform starts fine and fails at the first answer).
The architecture document covers tenancy, auditing and retention — the sections your data protection officer will ask about. Questions, corrections and patches are welcome at the repository; if a step on this page did not work for you, that is a bug in the page and worth reporting as one.
The platform is licensed under the EUPL 1.2. You may run it, change it and give it to anyone. If you distribute a modified version, that version stays under the same licence — work paid for once with public money should not quietly become somebody's closed product.
Co-funded by the European Union. Views and opinions expressed are however those of the author(s) only and do not necessarily reflect those of the European Union or the European Education and Culture Executive Agency (EACEA). Neither the European Union nor EACEA can be held responsible for them.
Erasmus+ KA210-VET small-scale partnership · project number KA210-VET-778D8F70 · March 2025 – December 2026