Skip to the article
Aniq-UI

LearnioEnvironment variables

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

FileRead byHolds secrets
back-end/.envThe API, with yarn dev and inside DockerYes. Never commit it.
frontend/.envThe student site, at build timeNo. Every value is public.
admin-dashboard/.envThe admin dashboard, at build timeNo. Every value is public.
.env beside docker-compose.ymlDocker Compose, for the full stackNo

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.

VariableWhat it does
NODE_ENVdevelopment locally, production on a live server.
PORTThe API's port, 8000. Both frontends point at it.
DB_TYPEsqlite (the default) or mysql.
SQLITE_DATABASEPath of the SQLite file, ./database.sqlite by default.
JWT_SECRETSigns every sign-in. Required. The example value is accepted in development only.
JWT_EXPIRATIONHow long a sign-in lasts, 7d by default.
CORS_ORIGINThe student site's and the admin's addresses, comma separated. Required in production.
FRONTEND_URLThe student site's address, used in links the API sends.

Generate your own JWT_SECRET with:

Terminal
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:

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-name

Then 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.

VariableAppWhat it does
NEXT_PUBLIC_API_BASE_URLBothThe API's address including /api, e.g. http://localhost:8000/api. Left empty, the app uses its sample data.
NEXT_PUBLIC_SITE_URLBothThe 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_HOSTNAMEBothYour media bucket's public host, without https://. Leave it empty until you have a bucket.
NEXT_PUBLIC_WEBSOCKET_BASE_URLAdminThe API's address without /api, for live notifications.
NEXT_PUBLIC_SAMPLE_DATA_NOTICEBothOptional. 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_URLStudent siteOptional, 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.

VariableWhat it does
SITE_PORT, ADMIN_PORT, API_PORTThe 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_URLWhere the browser reaches each app. Set all three when serving the stack on your own domains.
MEDIA_HOSTNAMEYour bucket's public host, with the R2_* variables in back-end/.env.
SEED_DEMO_DATAfalse 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.

back-end/.env
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

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.

back-end/.env
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_FROM must 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_name and support_email settings 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.

ProcessorVariablesWebhook endpoint
StripeSTRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRETPOST <api>/api/webhooks/payments/stripe
PayPalPAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_WEBHOOK_ID, PAYPAL_ENVPOST <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_failed and charge.refunded.
  • Subscribe PayPal to PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.REFUNDED and PAYMENT.CAPTURE.REVERSED.
  • PAYPAL_ENV is sandbox by default, which takes no real money. Live credentials are a different PayPal app, so going live means new keys as well as PAYPAL_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:mock in 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.

Stuck on a step?

Find a fix before you start over.

Troubleshooting

Cookie Preferences

We use cookies to enhance your browsing experience, analyze site traffic, and personalize content. By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising.