Environment variables
What each setting in the API's and the frontends' .env files does, and which ones you need.
For the Full Stack package
Where the settings live
| File | Read by | Holds secrets |
|---|---|---|
back-end/.env | The API, with yarn dev and inside Docker | Yes. Never commit it. |
frontend/.env | The student site, at build time | No. Every value is public. |
admin-dashboard/.env | The admin dashboard, at build time | No. Every value is public. |
.env beside docker-compose.yml | Docker Compose, for the full stack | No |
Create each file from the .env.example next to it, which documents every variable: cp .env.example .env. The examples work as they are for a local run.
Where the settings live
One file, frontend/.env, read when the site is built. Every value in it is public, so it never holds a secret. Create it from .env.example, which documents every variable: cp .env.example .env.
Where the settings live
One file, admin-dashboard/.env, read when the dashboard is built. Every value in it is public, so it never holds a secret. Leave it out to run on the mock; create it from .env.example when you connect an API: cp .env.example .env.
Where the settings live
One file, back-end/.env, read by the API with yarn dev and inside Docker. It holds secrets: never commit it. Create it from .env.example, which documents every variable and works as it is for a local run: cp .env.example .env.
API essentials
The API checks these before it starts. If one is missing or unusable, it stops with a short list of what to fix.
| Variable | What it does |
|---|---|
NODE_ENV | development locally, production on a live server. |
PORT | The API's port, 8000. Both frontends point at it. |
DB_TYPE | sqlite (the default) or mysql. |
SQLITE_DATABASE | Path of the SQLite file, ./database.sqlite by default. |
JWT_SECRET | Signs every sign-in. Required. The example value is accepted in development only. |
JWT_EXPIRATION | How long a sign-in lasts, 7d by default. |
CORS_ORIGIN | The student site's and the admin's addresses, comma separated. Required in production. |
FRONTEND_URL | The student site's address, used in links the API sends. |
Generate your own JWT_SECRET with:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"Use MySQL instead of SQLite
Create an empty database, then set the driver and the connection in back-end/.env:
DB_TYPE=mysql
DB_HOST=your-mysql-host
DB_PORT=3306
DB_USERNAME=your-mysql-username
DB_PASSWORD=your-mysql-password
DB_DATABASE=your-database-nameThen run yarn seed for the demo data, or yarn db:sync on a live site, which creates the tables and writes no rows. The running API only updates the schema itself when NODE_ENV=development.
Student site and admin dashboard
Every value here is NEXT_PUBLIC_*, compiled into the JavaScript the browser loads. Never put a secret in these files, and rebuild after changing one.
| Variable | App | What it does |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL | Both | The API's address including /api, e.g. http://localhost:8000/api. Left empty, the app uses its sample data. |
NEXT_PUBLIC_SITE_URL | Both | The student site's public address, used for canonical links, sharing previews and the sitemap. Set it to your real domain in production. |
NEXT_PUBLIC_MEDIA_HOSTNAME | Both | Your media bucket's public host, without https://. Leave it empty until you have a bucket. |
NEXT_PUBLIC_WEBSOCKET_BASE_URL | Admin | The API's address without /api, for live notifications. |
NEXT_PUBLIC_SAMPLE_DATA_NOTICE | Both | Optional. always shows the "Sample data" notice in a production build too when the API stops answering. The root docker-compose.yml sets it; a live site usually leaves it unset. |
API_INTERNAL_URL | Student site | Optional, server only. Where the site's own server reaches the API when that differs from the browser's address, as inside Docker Compose. |
Docker Compose options
Nothing has to be set for a local run. To change something, put it in a .env file next to docker-compose.yml and run docker compose up --build again: the frontends compile these values in.
| Variable | What it does |
|---|---|
SITE_PORT, ADMIN_PORT, API_PORT | The ports on your computer: 3030, 3031 and 8000 by default. Set one when another program already uses that port, such as ADMIN_PORT=3041. The addresses below, CORS_ORIGIN and FRONTEND_URL follow them. |
SITE_URL, ADMIN_URL, API_URL | Where the browser reaches each app. Set all three when serving the stack on your own domains. |
MEDIA_HOSTNAME | Your bucket's public host, with the R2_* variables in back-end/.env. |
SEED_DEMO_DATA | false starts with empty tables instead of the demo data. |
The API container also reads back-end/.env when it exists, so mail, payments, media and AI keys are set in one place for both yarn dev and Docker.
Media storage
Course art, avatars, blog images, lesson video and handouts are uploaded to a Cloudflare R2 bucket, or any S3-compatible one. Set the five variables in back-end/.env and give both frontends the bucket's public host through NEXT_PUBLIC_MEDIA_HOSTNAME.
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_ENDPOINT=
R2_BUCKET_NAME=
R2_PUBLIC_URL=Without a bucket, the seeder stores paths to sample files that both frontends ship themselves, so every picture and lesson video works locally. Only uploading new media stops working.
Email confirmations, password resets, receipts and enrolment confirmations are sent through Resend. Without RESEND_API_KEY, the app still runs and writes every message to its log.
RESEND_API_KEY=
MAIL_FROM=Learnio <noreply@example.com>
MAIL_MAX_PER_ADDRESS_PER_DAY=5
MAIL_MAX_PER_SENDER_PER_DAY=20- Use a sending-access key, not a full-access one.
MAIL_FROMmust be on a domain you have verified with Resend, or every send is rejected.- The two limits cap how many messages one address can receive and one sender can trigger per day.
- The product name inside each message and its reply-to address are not variables: they are the
site_nameandsupport_emailsettings in the admin's Settings, App Settings tab, read for every message sent.
Payments
Out of the box the checkout runs on a built-in simulator: no processor is contacted and nothing is charged. A card number ending in 0 is declined, so you can try the failure path.
Fill in a processor's keys and that payment method moves to the real processor. Configure both Stripe and PayPal before taking real orders: the checkout always offers card and PayPal, and an order paid through the simulator completes without anyone being charged.
Free courses (price 0) need no processor at all: their checkout completes on the API without one, whichever keys are set.
| Processor | Variables | Webhook endpoint |
|---|---|---|
| Stripe | STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET | POST <api>/api/webhooks/payments/stripe |
| PayPal | PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_WEBHOOK_ID, PAYPAL_ENV | POST <api>/api/webhooks/payments/paypal |
The webhook grants the seat
A student is enrolled when the processor confirms the payment through the webhook, not when they return from the payment page. Without the webhook subscription, orders stay pending.
- Subscribe Stripe to
checkout.session.completed,payment_intent.payment_failedandcharge.refunded. - Subscribe PayPal to
PAYMENT.CAPTURE.COMPLETED,PAYMENT.CAPTURE.DENIED,PAYMENT.CAPTURE.REFUNDEDandPAYMENT.CAPTURE.REVERSED. PAYPAL_ENVissandboxby default, which takes no real money. Live credentials are a different PayPal app, so going live means new keys as well asPAYPAL_ENV=live.
AI assistant
Included with your purchase. Sign in to read, or open it in your download.
Turning on the AI assistant: the provider keys, how each model is offered, and what the admin sees without a key.
Live classes
Included with your purchase. Sign in to read, or open it in your download.
Setting up live classes: connecting a Jitsi Meet server, secure join tokens, and the host controls.
Sample data in the frontends
Both frontends keep working when the API cannot be reached: they serve bundled sample data and show a "Sample data" notice at the bottom of the page. In a production build it appears only when no API is configured at all, unless the app was built with NEXT_PUBLIC_SAMPLE_DATA_NOTICE=always, as the root docker-compose.yml does.
- The admin dashboard's sample data covers sign-in, students, staff, roles, categories, settings, notifications, search and the assistant's chat history. Its other screens (the overview figures, courses, instructors, enrolments, orders, reviews, blog, schedule, quizzes and messages) need the API.
- The admin dashboard's sign-in accepts any email and password on the sample data, and keeps your changes in the browser. To start over, run
localStorage.removeItem("mock_db_v1"); location.reload();in the browser console. - The AI assistant is unavailable on the sample data, because it needs the API's keys.
- Once your API is live,
yarn remove:mockin either app deletes the sample data layer.
How the sample data layer works
Included with your purchase. Sign in to read, or open it in your download.
How requests are routed to the bundled data, and how to change or extend it.
Demo mode
Included with your purchase. Sign in to read, or open it in your download.
Running a public demo: the demo switch, per-visitor accounts and the message allowance, and how to remove it all.