Total recurring cost: $0.00 / month
From an empty folder to a working, multi-user school application on the public internet. Every step below is free. Nothing here asks for a credit card.
index.html in a browser and sign in with student@demo.school /
demo1234. The app runs in offline demo mode until you add Supabase keys, with sample
students, classes, assignments and grades. Demo mode stores data in that one browser only — it is
for evaluation, not for a real class.
Do this before anything else. Every later step says things like "paste
supabase/schema.sql" or "edit js/config.js" — those paths only make sense
once you have the project folder sitting on your own machine.
| Your website files | Supabase | |
|---|---|---|
| What it is | The ~50 HTML, CSS and JavaScript files in the trading-academy folder |
A Postgres database and the user accounts, running in the cloud |
| Where it lives | A folder on your computer → later copied to a free host (Cloudflare Pages) | supabase.com, in your browser. You never upload files to it |
| What you do to it | Edit one file (js/config.js), then upload the folder |
Paste SQL into its editor, flip a few auth settings, copy two values out |
js/config.js.
Website files never go into Supabase, and the database never goes into your folder.
Download trading-academy.zip (383 KB) from the workspace of the conversation where
this was built — click the file, then the download button.
.zip. A trading-academy folder
appears next to it..zip →
Extract All… → Extract. Open the extracted
folder; if you see another trading-academy folder inside it, that inner one
is the real project folder.Now drag that folder somewhere permanent and easy to type. The Desktop is perfectly fine:
| System | A good home for it |
|---|---|
| macOS | /Users/yourname/Desktop/trading-academy |
| Windows | C:\Users\yourname\Desktop\trading-academy |
This folder is "the project folder" that the rest of this guide refers to. Avoid leaving it inside Downloads (easy to lose) or in iCloud/OneDrive folders that sync and lock files.
Open it. You should see exactly this:
| Item | What it is | Do you touch it? |
|---|---|---|
index.html | The front page — the entry point of the whole site | No |
login.html, app.html, reset.html | The other pages | No |
js/ | All the JavaScript, including config.js | Yes — step 12 |
css/, assets/ | Styling and the offline Supabase library | No |
supabase/ | Three .sql files you will copy-paste into the Supabase website | Open and copy, never upload |
docs/ | This guide, plus the security and testing write-ups | Read |
tests/, tools/, README.md | Developer extras | Safe to ignore or delete |
Quick proof it is in the right place: double-click index.html. The app
should open in your browser with an orange DEMO MODE banner, and
student@demo.school / demo1234 should sign in. If that works, your folder is
fine and you can start at section 1.
You only need this for step 13 (testing locally). If you would rather skip it, you can go straight from section 5 to section 10, put the site online first, and do all your testing on the live URL.
| System | How |
|---|---|
| macOS | Right-click the trading-academy folder in Finder →
Services → New Terminal at Folder.Or: open Terminal, type cd (with the space), drag the folder
onto the window, press Enter. |
| Windows | Open the folder in File Explorer, click the white address bar at the top, type
cmd, press Enter.Or: right-click the folder → Open in Terminal. |
Confirm you are in the right place by listing the files — type ls on macOS or
dir on Windows. You should see index.html in the output. If you do not, you are
in the wrong folder and every command after this will fail.
.sql file from your project folder in a plain text editor
(TextEdit on macOS, Notepad on Windows, or VS Code), select everything with
Cmd+A / Ctrl+A, copy with Cmd+C /
Ctrl+C, and paste it into the Supabase SQL Editor in your browser. You are copying
text, not uploading a file. Nothing is dragged into Supabase at any point.
Go to supabase.com and click Start your project (top right). Sign in with GitHub or with an email address. The free plan needs no payment details.
On the dashboard click New project.
trading-academy.Click Create new project and wait roughly two minutes while it provisions.
In the left sidebar of your project click SQL Editor (the icon that looks like a terminal prompt). Then click New query at the top of the panel.
supabase/schema.sqlOpen supabase/schema.sql — that is the supabase sub-folder inside the
project folder you unzipped in section 0 — in a text editor, select everything
(Ctrl+A / Cmd+A), copy it, paste it into the SQL editor and
click Run (bottom right, or Ctrl+Enter).
It takes a few seconds. You should see Success. No rows returned. The script is idempotent: running it a second time is harmless.
This one file creates everything: 21 tables, 7 enum types, the authorisation helper functions, the guard triggers, Row Level Security on every table with 47 policies, 10 RPC functions, and a least-privilege grant block that strips all table access from anonymous visitors.
supabase/schema-math.sqlThis step is not optional. It creates the three tables the mathematics side of the app writes to. If you skip it, every maths page still loads and the practice problems still work, but nothing a student does is saved — the browser queues the attempts and keeps retrying against tables that do not exist.
Same routine as the previous step: New query → paste the whole of
supabase/schema-math.sql → Run. It is safe to run twice.
The output panel ends with a NOTICE that must read:
LearnAurelle math schema: 3 / 3 tables, 8 / 8 policies, 5 / 5 functions
What it adds:
math_skill_progress — one row per student per skill: attempts, correct,
current streak, best streak, time spent, and when the skill was mastered.math_attempts — an append-only log of every item attempted, including the
generator seed, so you can reproduce the exact question a student was shown.
There is deliberately no update or delete policy on this table.math_unit_state — which units a student has opened.Paste this into a new query and run it:
select count(*) filter (where rowsecurity) as tables_with_rls,
count(*) as tables_total
from pg_tables where schemaname = 'public';
Both numbers must be 24 — the original 21 tables plus the three added by
schema-math.sql. If tables_with_rls is lower, a schema file did not
finish — scroll up in the editor output for the error and re-run it.
submit_quiz(), which is scored
inside Postgres against an answer key the student's browser never receives. The maths
attempt log exists so a teacher can see effort and spot trouble, not to assign marks.
supabase/seed_curriculum.sqlExactly the same copy-paste routine as step 4, with the other file:
New query → paste the whole of
supabase/seed_curriculum.sql → Run. The final row must read
36 | 36 | 0: thirty-six lessons, thirty-six answer keys, none missing.
This is what makes graded quizzes trustworthy. The correct answers live in
lesson_answer_keys, which has RLS enabled and no read policy for students at
all. When a student submits a graded quiz the browser sends only their chosen option numbers;
public.submit_quiz() compares them inside the database and returns the score. A student
who reads every line of JavaScript this app serves still cannot obtain the key.
js/curriculum-*.js, then regenerate and re-run the seed so the
database key stays in step:node tools/gen_curriculum_sql.js > supabase/seed_curriculum.sql
Left sidebar → Authentication → Sign In / Providers → Email. Switch Confirm email off and click Save.
Authentication → URL Configuration.
https://your-project.pages.dev. While testing locally use
http://localhost:8080./reset.html on the end. Password
reset links will not work until these are listed.If every student has an address at the same domain, you can block everyone else. Authentication → Sign In / Providers → Email → Allowed email domains. This is the cheapest way to stop strangers creating accounts in your school.
The fastest way to get both at once: open your project and click the Connect button in the bar across the top of the page (next to the project name). The dialog that opens shows the Project URL and the publishable key together, with a copy button on each.
Supabase splits these two values across two different pages, so if you prefer the settings screens:
https://supabase.com/dashboard/project/abcdefghijklmnop. That bold part is
your project ref, and your Project URL is always
https://abcdefghijklmnop.supabase.co. Do not paste the
supabase.com/dashboard/… address itself into config.js — that is the dashboard,
not the API.| Field on the Supabase page | Goes into js/config.js as |
|---|---|
Project URL — https://abcdefgh.supabase.co | SUPABASE_URL |
Publishable key — sb_publishable_…(on older projects: the key labelled anon public) |
SUPABASE_PUBLISHABLE_KEY |
| service_role / secret key | Nowhere. Never. Not in any file in this project. |
| Label on the page | Looks like | Use it? |
|---|---|---|
| Project URL | https://abcdefgh.supabase.co |
Yes — this one |
| RESTful endpoint | https://abcdefgh.supabase.co/rest/v1/ |
No |
/rest/v1 and /auth/v1 itself. If you paste the longer one
it builds …/rest/v1/auth/v1/token and …/rest/v1/rest/v1/profiles, and every
login and every query fails with 404 PGRST125 — Invalid path specified in request URL.
The page still loads normally, so it looks like a login bug rather than a typo in a config file.
js/supabase.js now strips a trailing
/rest/v1, /auth/v1, /storage/v1 or /realtime/v1
automatically and prints a console warning. Fix config.js anyway so the warning goes away.
js/config.jswindow.LA_CONFIG = {
SUPABASE_URL: "https://abcdefgh.supabase.co",
SUPABASE_PUBLISHABLE_KEY: "sb_publishable_xxxxxxxxxxxxxxxxxxxx",
APP_NAME: "LearnAurelle",
SCHOOL_NAME: "Your School Name",
...
};
Save the file and reload the app. The orange DEMO MODE banner disappears — that is how you know it is talking to your real project.
This file is safe to commit to a public repository. Those two values are meant to be in a browser; your data is protected by the RLS policies, not by hiding the key.
Opening index.html straight from disk works for a quick look, but real authentication
behaves better over HTTP. Open a terminal inside the project folder
(section 0, step C) and run one of these:
| System | Command |
|---|---|
| macOS | python3 -m http.server 8080 |
| Windows | python -m http.server 8080 |
| Either, if you have Node instead of Python | npx serve -l 8080 |
The terminal will print something like Serving HTTP on 0.0.0.0 port 8080 and then appear to hang. That is correct — it is the server running. Leave that window open and switch to your browser:
http://localhost:8080
When you are finished, press Ctrl+C in the terminal to stop it.
npx serve line, or simply skip this step: double-clicking index.html is
enough to click through the app, and everything works properly once the site is deployed in
section 10. Local serving is a convenience, not a requirement.
Click Get started → the Create account tab. Enter your name, your email and a password of at least eight characters, tick the acknowledgement and submit.
Notice what you are not offered: there is no role selector. Everyone who signs up through the public form becomes a pending student with no school attached. There is no way to register as a teacher or an administrator, and no hard-coded administrator password exists anywhere in this project.
Supabase dashboard → Table Editor → profiles. Your row
should be there with role = student and status = pending, created
automatically by the handle_new_user trigger.
SQL Editor → new query → substitute your own address:
select public.bootstrap_first_admin('you@yourschool.org');
A successful run returns:
OK: you@yourschool.org is now an administrator of LearnAurelle.
You can also pass a school name and slug:
select public.bootstrap_first_admin(
'you@yourschool.org', 'Riverside High School', 'riverside');
Sign out and back in. You now land on the School Dashboard.
update public.schools
set name = 'LearnAurelle', slug = 'learnaurelle'
where slug = 'tradedesk';
Check what you actually have first with
select id, name, slug from public.schools; — if the row is already named after your
real school, leave it alone. The school name is what students see in the sidebar and in the
footer of emailed notifications.
bootstrap_first_admin has EXECUTE revoked from public,
anon and authenticated. It can only be called from the SQL editor or another
trusted connection — never from the browser. It also refuses to run if the school already has an
active administrator, so it cannot be used to seize an existing school. Every call is written to
audit_logs.
Run supabase/seed.sql in the SQL editor. It creates three classes with working join
codes, nine assignments covering every activity type, and some announcements. It never creates user
accounts — those have to come from real signups. It is safe to run more than once.
In the app: Users → Invite teacher/admin. Enter their email and choose Teacher. If they have already signed up they are promoted immediately; if not, the invitation applies the moment they register with that address.
Every role change is recorded in Audit Logs, including yours.
Classes → Create class. Give it a name and
pick a lead teacher. A teacher who is not listed on a class cannot see it at all —
that is checked by public.is_class_teacher() inside every relevant policy, not by hiding
buttons.
Codes are validated in the database by public.join_class_with_code(), which also checks
that the code has not been revoked. Press Rotate code and the old one stops
working for everybody instantly; students already enrolled are unaffected.
A brand-new account has no school until you approve it. Users → Approve next to their name. Until then they can read the Learning Academy but see no class work.
Approving does two things in a single database write: it sets the person's
school_id to your school and flips status from pending to
active. Both halves matter — see the warning below.
Where to find people waiting: the Overview page shows an amber "N accounts awaiting approval" banner, and Users → Any status → Pending lists them. A person with no school is also tagged not in a school yet under their name.
js/supabase.js sent only status: "active" and dropped
school_id. That combination is invisible to every branch of
can_view_profile: the approval-queue branch requires status='pending'
and the same-school branch requires a matching school_id. An account approved by that
build vanishes from the Users page and cannot be repaired from the interface. The current files
always send both fields. To find and rescue anyone already stranded, run this in the Supabase
SQL Editor (which runs as the table owner, so the guard trigger steps
aside):
-- who is stranded?
select id, email, full_name, role, status
from public.profiles
where school_id is null and status <> 'pending';
-- put them back in the approval queue, then click Approve in the app
update public.profiles set status = 'pending'
where school_id is null and status = 'active';
Create Assignment → Open the assignment form. Set a title, class, points and due date. Add rubric criteria if you want criterion-level marking. Pick an activity type — Lesson quiz is auto-graded by the database; the rest are graded by you.
Save it as a draft first if you like: students never see drafts. Click Publish now when it is ready.
To target only some students, open the assignment and use Assign to…. Students who are not targeted cannot load it even if they guess the address.
Assignments → open it → type an answer → Save draft (drafts live in the database, so they follow the student to any device and auto-save every 25 seconds) → Submit.
Submissions → Grade. The student's work is on the left, the marking form on the right. Enter rubric scores (the total fills in automatically), write private feedback, and choose:
A held-back grade is marked with an asterisk in the gradebook. Unreleased grades are
invisible to students at the database level: the students' SELECT policy on
grades includes and is_released, so the row is never sent to their browser.
Sign in as the student and open Grades & Feedback. The released grade, the percentage, the letter and the written feedback are all there. The Export CSV button downloads it.
This app is plain HTML, CSS and JavaScript with no build step, so any static host will serve it. Cloudflare Pages is used below because its free tier has unlimited bandwidth.
Already own a domain? Deploy with Option A or B first to get a working
.pages.dev address, then jump to
Putting it on your own domain below. It is free — free DNS, free HTTPS
certificate, no plan upgrade.
Create a free account at dash.cloudflare.com.
In the sidebar choose Workers & Pages →
Create → the Pages tab →
Upload assets. Give the project a name, then drag the whole
trading-academy folder from your Desktop onto the upload area and click
Deploy site.
This is the moment your files leave your computer — the folder from section 0 is copied to Cloudflare's servers, and that copy is what the world sees. Supabase is untouched by this; it has been holding your database the whole time.
You get a URL like https://trading-academy.pages.dev.
index.html to end up at the top level of the site — dragging the folder does
that correctly.js/config.js (step 12) before uploading.
You are uploading a snapshot; if you edit the file on your computer afterwards, re-upload to
publish the change.You may delete tests/, tools/ and README.md before uploading
if you prefer a tidy site. They are harmless either way — they contain no secrets, only SQL test
scripts and a generator script.
Push the folder to a GitHub repository, then in Cloudflare choose Workers & Pages → Create → Pages → Connect to Git, pick the repository and leave the build settings empty:
| Setting | Value |
|---|---|
| Framework preset | None |
| Build command | leave blank |
| Build output directory | / |
Click Save and Deploy. Every future git push redeploys
automatically.
Custom domains are free on every host below — free DNS, free automatic HTTPS certificate, no plan upgrade. The domain registration you already pay for is the only cost, and nothing here adds to it. Do not buy "web hosting" from your registrar. GoDaddy, Namecheap, Hostinger and the rest will try to sell you cPanel hosting at roughly $3–15/month; this app is static files and needs none of it.
.com to the Worker, and
how to redirect the other two without breaking email:
DOMAINS.html. Start there.
There is exactly one decision to make, and it changes the steps:
| What you want in the address bar | What it costs you in effort |
|---|---|
academy.yourdomain.com(a subdomain) |
One CNAME record at whoever runs your DNS today. Nothing else changes — your website, email and existing records are untouched. Five minutes. |
yourdomain.com(the bare apex domain) |
Cloudflare requires the domain to be a Cloudflare zone, which means moving your nameservers to Cloudflare. Still free, but Cloudflare then runs all of your DNS. Alternatively use GitHub Pages, which does apex with plain A records and no nameserver change. |
academy.yourdomain.com is the lowest-risk option by a wide margin: it cannot break your
existing website or email, and you can undo it by deleting one DNS record.
Deploy first using Option A or B above, so you have a working
something.pages.dev address. Then:
academy.yourdomain.com → Continue.| Type | Name / Host | Value / Points to | TTL |
|---|---|---|---|
CNAME | academy |
your-project.pages.dev | Auto / 3600 |
academy in the Name field, not the full
academy.yourdomain.com — they append the domain for you.Add the domain to Cloudflare as a zone: in the dashboard click
Add a domain, enter yourdomain.com, choose the
Free plan, and let Cloudflare scan your existing DNS records. Then change the
nameservers at your registrar to the two Cloudflare gives you. Once the zone is active, go to your
Pages project → Custom domains → Set up a domain
→ enter yourdomain.com, and Cloudflare writes the record itself.
MX,
TXT/SPF, DKIM and DMARC records were all copied into Cloudflare's scan before
you change the nameservers at your registrar. A missed MX record means mail stops arriving, and that
is the one mistake here with real consequences.
If you want the bare domain but will not move DNS, GitHub Pages handles apex with ordinary A
records. Push the project to a public GitHub repository, then repository
Settings → Pages → set the source branch, and
under Custom domain enter yourdomain.com and save. At your DNS provider
add these four A records, all with the name @:
185.199.108.153
185.199.109.153
185.199.110.153
185.199.111.153
Then tick Enforce HTTPS in the same GitHub settings page — it can take up to 24 hours to become available while the certificate is issued. GitHub Pages is free with a 1 GB site limit and a soft 100 GB/month bandwidth limit, both far beyond what this app uses.
| Host | Cost | Bandwidth | Custom domain + HTTPS | Apex without moving nameservers? |
|---|---|---|---|---|
| Cloudflare Pages | $0 | Unlimited | Free, up to 100 domains per project | No — apex needs a Cloudflare zone |
| GitHub Pages | $0 | 100 GB/mo soft limit | Free | Yes, via A records |
| Netlify | $0 | Credit-based since Sept 2025; free tier ≈ 15 GB/mo | Free | Yes |
Cloudflare Pages is the recommendation because it is the only one of the three with no traffic cap at all, which removes any chance of a surprise bill or a suspended site. Netlify's free plan moved to a credit system and is now the tightest of the three.
Supabase has to be told which address your app lives at, or email confirmation links and password resets will quietly fail. Go to Authentication → URL Configuration.
| Field | How many values | What it is |
|---|---|---|
| Site URL | Exactly one | Your canonical address. Supabase inserts it into confirmation and password-reset emails. |
| Redirect URLs | A list | An allow-list of every address permitted to receive a login redirect. Anything not listed is refused. |
| Field | What to enter |
|---|---|
| Site URL | Whatever address people will actually use. If your custom domain is live, that
(https://yourdomain.com). If it is not connected yet, use the host's address
(https://your-site.pages.dev or https://your-worker.workers.dev)
and change it later. |
| Redirect URLs | Add every one of these that applies, one per line:https://yourdomain.com/**https://www.yourdomain.com/**https://your-site.pages.dev/** — keep it as a fallbackhttp://localhost:8080/** — keep it if you test locally |
The trailing /** is a wildcard that covers every page on that address, so you do not
have to list /reset.html and the rest one by one. Listing an address that is not live yet
is harmless. Password resets fail silently when this is wrong, so test one reset after
changing it.
On a phone, open the deployed URL and sign in as the student account. You should see the same classes, the same drafts and the same grades you saw on the desktop — because the data lives in Postgres, not in the browser. That is the actual test of whether the app is really multi-user.
Things worth checking on a phone:
| Service | Plan | Cost | What you get |
|---|---|---|---|
| Supabase | Free | $0 | 500 MB database, 1 GB file storage, 5 GB egress, 50,000 monthly active users, 500,000 Edge Function calls, 2 active projects per organisation |
| Cloudflare Pages | Free | $0 | Unlimited bandwidth, 500 builds per month, global CDN, automatic HTTPS |
| GitHub | Free | $0 | Optional. Unlimited public and private repositories |
| Market data | — | $0 | None purchased. All prices are generated locally and labelled synthetic |
| Total | $0.00 / month | ||
The paid Supabase Pro plan is $25 per organisation per month, which is well over the $10 budget this project was built to. Nothing in this application requires it.
| Symptom | Cause and fix |
|---|---|
| Orange DEMO MODE banner will not go away | js/config.js still has empty strings, or the file was not saved. Hard-reload with
Ctrl+Shift+R. |
| "No profile found" after signing in | The handle_new_user trigger is missing — schema.sql did not finish.
Re-run it; it is safe to run twice. |
| Signing up returns "error sending confirmation email" | You have hit the ~2 emails/hour limit. Turn off email confirmation (step 8) or add custom SMTP. |
| Teacher sees "You are not assigned to any class" | Correct behaviour. An administrator must add them on the class page under Teachers. The database returns nothing until then. |
| Student sees no assignments | They are not enrolled, their account is still pending, or the assignment is a
draft. Check all three in that order. |
| Password reset email link does nothing | /reset.html is not in the Redirect URLs list (step 9). |
| "permission denied for table …" | RLS is working. Either the signed-in user genuinely may not do that, or they need to be enrolled/authorised first. Check Audit Logs and the user's role. |
| Everything is slow after a holiday | The project paused. Open the Supabase dashboard and click Restore project. |