diff --git a/.env.example b/.env.example
index 56faca5e..8ec0b0c0 100644
--- a/.env.example
+++ b/.env.example
@@ -12,7 +12,7 @@
# - For production, PIN an exact release (e.g. 1.4.2) so a merge to main never auto-breaks you.
# "1.4.2" / "1.4" / "1" are immutable, published on a v* git tag.
# Rollback = set this back to the previous version, then `... pull && up -d`.
-CIO_VERSION=latest
+CIO_VERSION=1
# ──────────────────────────────────────────────
# Required
diff --git a/apps/docs/content/docs/self-hosted/coolify.mdx b/apps/docs/content/docs/self-hosted/coolify.mdx
index 907bb790..02c6232c 100644
--- a/apps/docs/content/docs/self-hosted/coolify.mdx
+++ b/apps/docs/content/docs/self-hosted/coolify.mdx
@@ -1,55 +1,277 @@
---
title: Coolify
-description: Deploy ClassroomIO on Coolify using the pull-based Docker Compose stack.
+description: Deploy ClassroomIO on Coolify using the pull-based Docker Compose stack, with a video walkthrough.
---
import { Callout } from 'fumadocs-ui/components/callout';
+import { Step, Steps } from 'fumadocs-ui/components/steps';
-[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It deploys ClassroomIO as a **Docker Compose resource** using the pull-based [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml) — so your Coolify host **pulls** the published `classroomio/*` images and never builds anything.
+[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It runs ClassroomIO from the published `classroomio/*` images — your Coolify host **pulls** them and never builds anything.
+
+## Watch the walkthrough
+
+Prefer to follow along? This video walks through the whole Coolify deployment end to end. The written steps below mirror it, and the [Environment variable reference](#environment-variable-reference) has every value to copy.
+
+{/* TODO: replace REPLACE_WITH_VIDEO_ID with the Coolify walkthrough video id once recorded */}
+
+
+
## Prerequisites
-- A running Coolify instance with a connected server.
-- A domain pointed at that server (Coolify's built-in proxy terminates HTTPS for you).
+- A running **Coolify instance** with a connected server.
+- A **domain** with a DNS **A record pointing at your Coolify server's IP**. Coolify's built-in proxy terminates HTTPS and issues the certificate for you.
+- An **SMTP provider** (or Zoho/ZeptoMail) for signup verification, password reset, and invite emails. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/).
+- *Optional:* an **OpenAI API key** (transcription + AI course generation) and Google OAuth / Unsplash keys.
-## Steps
+
+ You'll reuse `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` across services. Coolify does **not**
+ run our secret-generation script, so generate them once yourself and keep them handy:
-1. **New Resource → Docker Compose.** Point Coolify at this repo and the compose path `docker-compose.images.yaml`, or paste the file's contents.
+ ```zsh title="Terminal"
+ openssl rand -hex 32 # BETTER_AUTH_SECRET
+ openssl rand -hex 32 # PRIVATE_SERVER_KEY (must be identical in the api and dashboard services)
+ ```
+
-2. **Set environment variables** (Coolify's UI — these become the compose `.env`). Coolify does **not** run our `run-docker-full-stack.sh`, so generate secrets yourself:
+## Step-by-step walkthrough
- ```zsh title="Required"
- CIO_VERSION=1.4.2 # pin a release (not "latest") so upgrades are deliberate
- DASHBOARD_ORIGIN=https://app.yourdomain.com
- BETTER_AUTH_SECRET=
- PRIVATE_SERVER_KEY= # must be identical in the api and dashboard services
- POSTGRES_PASSWORD=
- # Email (required for signup/reset/invites)
- SMTP_HOST=
- SMTP_PORT=
- SMTP_USER=
- SMTP_PASSWORD=
- SMTP_SENDER=
- ```
+This uses a **Docker Compose resource**: you paste the compose file and set every environment value yourself. The flow is: **create a project → add the compose file → set environment variables → point the dashboard domain → deploy → sign up**.
-
- `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` are required. The compose file fails fast if
- either is empty, so it never boots with a predictable session-signing secret.
-
+
-3. **Assign a domain.** Map your domain to the **dashboard** service (internal port `3082`); Coolify's reverse proxy issues the certificate. The dashboard proxies API calls server-side, so you normally only need the dashboard exposed publicly — the `api` service stays internal.
+
+### Create a project and add a Docker Compose resource
-4. **Object storage.** Recommended: the **bundled MinIO** — enable its profile with
- `COMPOSE_PROFILES=minio` and Coolify runs MinIO alongside the stack. Give the MinIO volume
- persistent storage in Coolify and route its public URL (`OBJECT_STORAGE_PUBLIC_ENDPOINT` /
- `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`) so browsers can load media. Prefer fully-managed
- storage instead? Point the same `OBJECT_STORAGE_*` variables at external **S3 / Cloudflare R2**
- and leave the `minio` profile off — see the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
+Log in to Coolify and **create a project**. When you add a resource, Coolify asks what type it is — GitHub, a Docker image, a service, or a **Docker Compose** file. Choose **Docker Compose**, then **paste the contents of [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml)** into the editor.
- **Upload size limits** (optional): set `UPLOAD_MAX_*_MB` in Coolify's env for both the `api` and
- `dashboard` services (same values in each). See [Docker self-hosting → Upload file size limits](/docs/self-hosted/docker#optional--upload-file-size-limits).
+
-5. **Deploy.** Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up.
+
+
+
+### Confirm the services parsed
+
+After saving, Coolify parses the compose file and lists the services: `postgres`, `redis`, `api`, `dashboard`, `jobs` (and `minio` once you enable its profile in the next step). Confirm they all appear before moving on.
+
+
+
+
+
+### Set the environment variables
+
+Open the resource's **Environment Variables** page. This is where most Coolify deployments go wrong, so treat the [Environment variable reference](#environment-variable-reference) below as the source of truth: **set every variable it lists as required, and leave the optional ones blank.** The block here covers the essentials to get you started; the full copy-paste reference is a few sections down.
+
+```zsh title="Essentials — set all of these"
+COMPOSE_PROFILES=minio # runs the bundled MinIO object storage
+DASHBOARD_ORIGIN=https://dash.yourdomain.com
+BETTER_AUTH_SECRET=
+PRIVATE_SERVER_KEY= # must be identical in the api and dashboard services
+POSTGRES_PASSWORD=
+# Email (signup verification, password reset, invites)
+SMTP_HOST=smtp.yourprovider.com
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASSWORD=
+SMTP_SENDER=ClassroomIO
+# Object storage + domains: see the full reference below
+```
+
+👉 The essentials above get you started, but the complete list of what to set — object storage, domains, and every optional variable — is in the [**Environment variable reference**](#environment-variable-reference) below. Copy that full block before you deploy.
+
+
+ There's no way to guess which variables Coolify needs, so the [Environment variable
+ reference](#environment-variable-reference) spells it out. The rule is simple: **every variable
+ shown with a value must be set** for a smooth deployment (secrets, database, domains, object
+ storage, and SMTP are all needed). **Every variable left blank after `=` is optional** — fill it in
+ only if you use that integration. Leaving an optional one blank never causes a build or boot error.
+
+
+
+
+
+
+### Confirm the image tag
+
+`CIO_VERSION` chooses the tag pulled for `classroomio/{api,dashboard,jobs}`. **Confirm the tag you use actually exists** at [hub.docker.com/r/classroomio](https://hub.docker.com/r/classroomio/) before deploying.
+
+
+ Set `CIO_VERSION=1.0.0` to pin an exact release. `latest` is the freshest `main` build — it moves
+ on every merge (rolling; may include unreleased code), while the version tags (`1.0.0`, `1.0`, `1`)
+ are immutable, pinned releases. Pinning an exact release makes upgrades deliberate and deploys
+ reproducible.
+
+
+
+
+
+
+### Set the dashboard domain
+
+Open the **dashboard** service → **Settings** and set its domain, including the internal port, e.g. `https://dash.yourdomain.com:3082`. Coolify maps that FQDN to the container's port `3082` and issues the certificate through its proxy.
+
+The dashboard proxies API calls server-side, so you normally only expose the dashboard publicly — the `api` service stays internal.
+
+
+
+
+
+### Deploy
+
+Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up. Watch the **logs** — they tell you when every service is up.
+
+
+
+
+
+### Open the app and sign up
+
+Once the logs show the stack is healthy, open your dashboard URL (`https://dash.yourdomain.com`) in a new browser tab and **sign up**. The **first** account becomes the organization **admin**. Every signup or sign-in after that joins as a student — unless you invite them with a specific role.
+
+
+
+
+
+
+## Environment variable reference
+
+You set these on the resource's Environment Variables page (Step 3). This is the complete picture — copy the block below, then replace the placeholder values with your own.
+
+
+ **Every variable shown with a value below is required** for a working deployment. **Every variable
+ left blank after `=` is optional** — fill it in only if you use that feature. A blank optional
+ variable never causes a build or boot error, so don't put junk into fields you aren't using.
+
+
+```zsh title=".env — a complete working Coolify deployment"
+# ── Secrets (generate with: openssl rand -hex 32) ──
+BETTER_AUTH_SECRET=
+PRIVATE_SERVER_KEY= # identical in the api and dashboard services
+
+# ── Object storage profile ──
+COMPOSE_PROFILES=minio # runs the bundled MinIO alongside the stack
+
+# ── Database ──
+POSTGRES_DB=classroomio
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=
+
+# ── Domains & routing ──
+PUBLIC_IS_SELFHOSTED=true
+DASHBOARD_ORIGIN=https://dash.yourdomain.com
+PUBLIC_SERVER_URL=https://dash.yourdomain.com
+PRIVATE_SERVER_URL=http://api:3081
+PRIVATE_APP_HOST=dash.yourdomain.com
+PRIVATE_APP_SUBDOMAINS=app
+
+# ── Object storage (MinIO) ──
+MINIO_ROOT_USER=
+MINIO_ROOT_PASSWORD=
+MINIO_SERVER_URL=https://storage.yourdomain.com
+OBJECT_STORAGE_ENDPOINT=http://minio:9000
+OBJECT_STORAGE_PUBLIC_ENDPOINT=https://storage.yourdomain.com
+OBJECT_STORAGE_ACCESS_KEY_ID=
+OBJECT_STORAGE_SECRET_ACCESS_KEY=
+OBJECT_STORAGE_FORCE_PATH_STYLE=true
+OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=https://storage.yourdomain.com/media
+OBJECT_STORAGE_BUCKET_VIDEOS=videos
+OBJECT_STORAGE_BUCKET_DOCUMENTS=documents
+OBJECT_STORAGE_BUCKET_MEDIA=media
+CSP_MEDIA_SRC_DOMAINS=https://storage.yourdomain.com # so browsers can load media
+
+# ── Email (SMTP — signup verification, password reset, invites) ──
+SMTP_HOST=smtp.yourprovider.com
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASSWORD=
+SMTP_SENDER=ClassroomIO
+
+# ── Optional — leave blank unless you need them ──
+CIO_VERSION=1.0.0 # pinned release; blank = latest (rolling main build)
+GOOGLE_CLIENT_ID= # Google OAuth sign-in
+GOOGLE_CLIENT_SECRET=
+OPENAI_API_KEY= # transcription + AI course generation
+UNSPLASH_API_KEY= # Unsplash image search
+LICENSE_KEY= # enterprise features (SSO)
+ALLOWED_EXTERNAL_DOMAINS= # overrides all CSP_*_DOMAINS below
+CSP_SCRIPT_SRC_DOMAINS=
+CSP_STYLE_SRC_DOMAINS=
+CSP_CONNECT_SRC_DOMAINS=
+CSP_FRAME_SRC_DOMAINS=
+CSP_FONT_SRC_DOMAINS=
+TRUSTED_ORIGINS= # only for direct browser-to-API calls (proxy is same-origin)
+OBJECT_STORAGE_REGION= # only for some external S3 providers
+SKIP_DB_SETUP= # blank = run DB setup on first boot (leave blank)
+```
+
+### Required — set all of these
+
+| Variable | What it's for |
+|----------|---------------|
+| `BETTER_AUTH_SECRET` | Session-signing secret. Generate with `openssl rand -hex 32`. |
+| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key. Must be **identical** in the api and dashboard services. |
+| `COMPOSE_PROFILES` | Set to `minio` to run the bundled object storage with the stack. |
+| `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | Database name, user, and a **strong** password. |
+| `PUBLIC_IS_SELFHOSTED` | `true` for self-hosted mode. |
+| `DASHBOARD_ORIGIN` | Public dashboard URL — used as the auth base URL and in invite/email links. |
+| `PUBLIC_SERVER_URL` | Public origin the browser uses (your dashboard domain). |
+| `PRIVATE_SERVER_URL` | Internal API URL over the Docker network (`http://api:3081`). |
+| `PRIVATE_APP_HOST` / `PRIVATE_APP_SUBDOMAINS` | Dashboard host and subdomain routing. |
+| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | MinIO credentials — also used as the object-storage access key / secret. |
+| `MINIO_SERVER_URL` | Public storage URL (fixes MinIO console redirects behind the proxy). |
+| `OBJECT_STORAGE_ENDPOINT` | Internal endpoint the app uploads to (`http://minio:9000`). |
+| `OBJECT_STORAGE_PUBLIC_ENDPOINT` | Public storage endpoint browsers download from. |
+| `OBJECT_STORAGE_ACCESS_KEY_ID` / `OBJECT_STORAGE_SECRET_ACCESS_KEY` | Storage credentials (match the MinIO root user/password). |
+| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` — required for MinIO. |
+| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | Public base URL for media (`…/media`). |
+| `OBJECT_STORAGE_BUCKET_VIDEOS/DOCUMENTS/MEDIA` | Bucket names (`videos`, `documents`, `media`). |
+| `CSP_MEDIA_SRC_DOMAINS` | Your storage domain — otherwise the browser's CSP blocks uploaded media. |
+| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SENDER` | SMTP server for signup verification, password reset, and invites. |
+
+
+ Of the above, `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` are the two the compose file
+ **fails fast** on: leave either empty and the containers refuse to boot (so ClassroomIO never runs
+ with a predictable session-signing secret). The rest won't abort the boot, but the deployment
+ won't work correctly without them — set them all.
+
+
+### Optional — leave blank unless you need them
+
+| Variable | Enables |
+|----------|---------|
+| `CIO_VERSION` | Image tag to pull. Blank = `latest` (rolling main); pin an exact release such as `1.0.0` (see the tag step). |
+| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google OAuth sign-in. |
+| `OPENAI_API_KEY` | Transcription + AI course generation (jobs worker). |
+| `UNSPLASH_API_KEY` | Unsplash image search. |
+| `LICENSE_KEY` | Enterprise features — SSO requires a license ([book a call](http://cal.com/classroomio/enterprise)). |
+| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all `CSP_*_DOMAINS` vars with one comma-separated allowlist. |
+| `CSP_SCRIPT_SRC_DOMAINS`, `CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS` | Per-directive CSP allowlists for extra CDN/font/frame domains. |
+| `TRUSTED_ORIGINS` | Extra origins allowed to call the API directly (normal dashboard traffic is same-origin via the proxy). |
+| `OBJECT_STORAGE_REGION` | Region for some external S3 providers (not needed for MinIO). |
+| `SKIP_DB_SETUP` | Skips first-boot database setup — leave blank so it runs. |
+| `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding / meta tags. |
+
+### Object storage
+
+Recommended: the **bundled MinIO** — enable its profile with `COMPOSE_PROFILES=minio` and Coolify
+runs MinIO alongside the stack. Give the MinIO volume persistent storage in Coolify and route its
+public URL (`OBJECT_STORAGE_PUBLIC_ENDPOINT` / `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`) so browsers
+can load media. Prefer fully-managed storage instead? Point the same `OBJECT_STORAGE_*` variables
+at external **S3 / Cloudflare R2** and leave the `minio` profile off — see the
+[Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
+
+
+ Single-node MinIO has no redundancy — its volume is the only thing protecting your uploads. Give
+ it persistent storage in Coolify and back it up. If you'd rather not operate storage, use external
+ **Cloudflare R2 / AWS S3** instead (R2 has no egress fees): point the same `OBJECT_STORAGE_*`
+ vars at the provider.
+
+
+### Upload size limits (optional)
+
+Set `UPLOAD_MAX_*_MB` in Coolify's env for both the `api` and `dashboard` services (same values in
+each), e.g. `UPLOAD_MAX_DOCUMENT_MB=50`. See
+[Docker self-hosting → Upload file size limits](/docs/self-hosted/docker#optional--upload-file-size-limits).
## Upgrades & rollback
diff --git a/apps/docs/content/docs/self-hosted/docker.mdx b/apps/docs/content/docs/self-hosted/docker.mdx
index c17a5bde..6dcc079d 100644
--- a/apps/docs/content/docs/self-hosted/docker.mdx
+++ b/apps/docs/content/docs/self-hosted/docker.mdx
@@ -30,7 +30,7 @@ curl -o .env https://raw.githubusercontent.com/classroomio/classroomio/main/.env
2. Edit `.env`. At minimum set `CIO_VERSION` (pin a release), `DASHBOARD_ORIGIN`, the auth secrets, and SMTP:
```zsh title=".env"
-CIO_VERSION=1.4.2 # pin a release (not "latest") so upgrades are deliberate
+CIO_VERSION=1.0.0 # pin a release (not "latest") so upgrades are deliberate
DASHBOARD_ORIGIN=https://app.yourdomain.com
BETTER_AUTH_SECRET=
PRIVATE_SERVER_KEY= # must be identical in the api and dashboard
@@ -112,8 +112,8 @@ The script builds and starts Postgres, Redis, MinIO (object storage), the API, t
ClassroomIO publishes versioned images. `CIO_VERSION` controls which tag you run:
-- Pin an exact release (e.g. `1.4.2`) in production so a merge to `main` never restarts you onto changed images unattended.
-- `latest` = the freshest `main` build — it moves on every merge (rolling; may include unreleased code). Version tags (`1.4.2`, `1.4`, `1`) are the immutable, pinned releases.
+- Pin an exact release (e.g. `1.0.0`) in production so a merge to `main` never restarts you onto changed images unattended.
+- `latest` = the freshest `main` build — it moves on every merge (rolling; may include unreleased code). Version tags (`1.0.0`, `1.0`, `1`) are the immutable, pinned releases.
**Pre-built images path** — bump `CIO_VERSION` in `.env`, then re-pull and recreate:
diff --git a/apps/docs/content/docs/self-hosted/railway.mdx b/apps/docs/content/docs/self-hosted/railway.mdx
index 36452101..ca0edd10 100644
--- a/apps/docs/content/docs/self-hosted/railway.mdx
+++ b/apps/docs/content/docs/self-hosted/railway.mdx
@@ -4,14 +4,148 @@ description: Deploy ClassroomIO on Railway with the one-click template.
---
import { Callout } from 'fumadocs-ui/components/callout';
+import { Step, Steps } from 'fumadocs-ui/components/steps';
The quickest hosted deployment is the [Railway Template](https://railway.com/deploy/classroomio-1?referralCode=8OyIj5). It provisions the API, dashboard, and jobs worker services plus the Postgres and Redis plugins for you.
[](https://railway.com/deploy/classroomio-1?referralCode=8OyIj5)
-After deploying the template, fill in the environment variables for each service below.
+## Watch the walkthrough
-## cio-api
+Prefer to follow along? This video walks through the whole deployment end to end. The written steps below mirror it, and the [Environment variable reference](#environment-variable-reference) has every value to copy.
+
+
+
+
+
+## Prerequisites
+
+- A **Railway account** (a paid plan is recommended — the stack runs several always-on services).
+- An **SMTP provider** (or Zoho/ZeptoMail) for signup verification, password reset, and invite emails. See [SMTP Setup](#smtp-setup).
+- *Optional:* a **custom domain** for the dashboard and storage, an **OpenAI API key** (transcription + AI course generation), and Google OAuth / Unsplash keys.
+
+
+ You'll reuse `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` across services. Generate them once and keep them handy:
+
+ ```zsh title="Terminal"
+ openssl rand -hex 32 # BETTER_AUTH_SECRET
+ openssl rand -hex 32 # PRIVATE_SERVER_KEY (must be identical in cio-api and cio-dashboard)
+ ```
+
+
+## Step-by-step walkthrough
+
+The one-click template provisions **all of the services** for you — Postgres, Redis, `cio-api`, `cio-dashboard`, `cio-jobs`, and `cio-minio` (object storage). The flow is: **configure a few values → deploy → connect the dashboard domain → sign up**.
+
+
+
+
+
+### Click "Deploy on Railway"
+
+Click the **Deploy on Railway** button above. Railway opens the template page where all of the ClassroomIO services are listed and ready to configure.
+
+
+
+
+
+### Review the preconfigured services
+
+The template comes with every service preconfigured — most need nothing from you:
+
+- **Redis** and **Postgres** — no configuration required.
+- **cio-dashboard** — no required configuration (optionally set branding such as the public OG image; not required).
+
+The next three steps cover the services that **do** need input: `cio-jobs`, `cio-minio`, and `cio-api`.
+
+
+
+
+
+### Configure cio-jobs (SMTP)
+
+`cio-jobs` sends invites, notifications, verification, and password-reset emails, so it needs SMTP credentials. Fill these in:
+
+- `SMTP_HOST`
+- `SMTP_PORT`
+- `SMTP_USER`
+- `SMTP_SENDER`
+- `SMTP_PASSWORD`
+
+
+
+
+
+### Configure cio-minio (object storage)
+
+Set the storage credentials on `cio-minio`:
+
+- `MINIO_SERVER_URL` — optional but recommended (fixes MinIO console redirects behind the proxy); the template presets it to the generated `cio-minio` domain, so you normally just confirm it
+- `MINIO_ROOT_USER`
+- `MINIO_ROOT_PASSWORD`
+
+
+
+
+
+### Configure cio-api
+
+On `cio-api`, set the SMTP host and any additional values you want. See the [Environment variable reference](#environment-variable-reference) below for the full list (object storage, auth secrets, dashboard origin, and more).
+
+
+
+
+
+
+### Deploy
+
+Click **Deploy**. Railway begins building and bringing the services online — Redis, Postgres, and the rest come up first.
+
+
+
+
+
+### First boot may crash — this is expected
+
+
+ On the first deploy, `cio-api` can crash. This is expected **only when you rely on the Railway-generated subdomain**: the API needs the dashboard's public domain (Better Auth's base URL), which doesn't exist yet. If you're using your **own custom subdomain**, this crash doesn't happen — skip ahead to signing up.
+
+
+
+
+
+
+### Generate the dashboard domain
+
+Open **cio-dashboard → Settings → Networking** and click **Generate Domain**. Railway assigns the public domain the API needs.
+
+
+
+
+
+### Redeploy cio-dashboard and cio-api
+
+Redeploy **cio-dashboard**, then **cio-api**, so both pick up the newly generated domain. They should now boot cleanly.
+
+
+
+
+
+
+### Open the app and sign up
+
+Open the generated dashboard URL in a **new browser tab** and **sign up** to create your first account.
+
+
+
+
+
+
+## Environment variable reference
+
+You set these values **while configuring each service on the template page** (the configure steps above) — you don't enter them again after deploying. Use this section as the reference for what each service's variables should be.
+
+### cio-api
```zsh title="env"
DATABASE_URL="${{db.DATABASE_URL}}"
@@ -47,7 +181,7 @@ OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=https://storage.yourdomain.com/media
Optional: add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` for OAuth, `UNSPLASH_API_KEY` for image search, or `LICENSE_KEY` for enterprise features.
-## cio-dashboard
+### cio-dashboard
```zsh title="env"
# RAILWAY_PUBLIC_DOMAIN here = this dashboard service's own public domain.
@@ -64,7 +198,7 @@ Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL
**Upload size limits** (optional): add the same `UPLOAD_MAX_*_MB` values to **cio-api** and **cio-dashboard** (e.g. `UPLOAD_MAX_DOCUMENT_MB=50`). See [Docker self-hosting → Upload file size limits](/docs/self-hosted/docker#optional--upload-file-size-limits).
-## cio-jobs (background worker)
+### cio-jobs (background worker)
The jobs worker has **no public domain** — it drains the Redis queue (video processing, transcription, AI course generation, and emails). It shares the same Postgres, Redis, storage, email, and OpenAI settings as the API:
@@ -91,22 +225,18 @@ OPENAI_API_KEY=
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API. Referencing `${{cio-api.PRIVATE_SERVER_KEY}}` in the dashboard keeps them in sync.
-## cio-minio (object storage)
+### cio-minio (object storage)
-We recommend a self-hosted **MinIO** service for object storage. Add it as a new service from
-the Docker image `minio/minio:latest`:
-
-- **Start command:** `server /data --console-address ":9001"`
-- **Volume:** attach a Railway Volume mounted at **`/data`** — **required**, or every upload is
- wiped on each redeploy.
-- **Public domain:** e.g. `storage.yourdomain.com` with **target port `9000`** (the S3 API).
- Railway also gives it a private domain (`cio-minio.railway.internal:9000`) for the api/jobs.
+`cio-minio` ships **in the template** — its `/data` volume is already attached and the `videos`,
+`documents`, and `media` buckets are already seeded. You only set its credentials during
+configuration:
```zsh title="env"
MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD=
-# Optional (silences console redirect warnings behind the proxy):
-MINIO_SERVER_URL=https://storage.yourdomain.com
+# Recommended (not required — ClassroomIO reaches storage via OBJECT_STORAGE_* below;
+# this only fixes MinIO's own console redirects behind the proxy). Template presets it.
+MINIO_SERVER_URL=https://${{cio-minio.RAILWAY_PUBLIC_DOMAIN}}
```
The api and jobs services reach MinIO over **two** endpoints: the private `OBJECT_STORAGE_ENDPOINT`
@@ -114,28 +244,17 @@ The api and jobs services reach MinIO over **two** endpoints: the private `OBJEC
(browser-facing presigned downloads and public images) — already wired in the env blocks above.
`OBJECT_STORAGE_FORCE_PATH_STYLE=true` is required for MinIO.
-### Seed the buckets (one-time)
-
-Railway has no `minio-init`, so create the buckets once with [`mc`](https://min.io/docs/minio/linux/reference/minio-mc.html)
-(run locally against the public domain):
-
-```zsh title="Terminal"
-mc alias set cio https://storage.yourdomain.com "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
-mc mb cio/videos cio/documents cio/media --ignore-existing
-mc anonymous set download cio/media # media bucket = public read (images)
-```
-
Single-node MinIO has no redundancy — the Railway Volume is the only thing protecting your
uploads. Back it up. If you'd rather not operate storage, use external **Cloudflare R2 / AWS S3**
- instead (R2 has no egress fees): point the same `OBJECT_STORAGE_*` vars at the provider and skip
- the cio-minio service. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
+ instead (R2 has no egress fees): point the same `OBJECT_STORAGE_*` vars at the provider. See the
+ [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
-## SMTP Setup
+### SMTP Setup
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider setup.
-## SSO (Enterprise)
+### SSO (Enterprise)
SSO requires a license key. Set `LICENSE_KEY` in your API environment. [Book a call](http://cal.com/classroomio/enterprise) to get a license.
diff --git a/apps/docs/public/coolify-01-add-resource.png b/apps/docs/public/coolify-01-add-resource.png
new file mode 100644
index 00000000..ae881758
Binary files /dev/null and b/apps/docs/public/coolify-01-add-resource.png differ
diff --git a/apps/docs/public/coolify-01-new-resource.png b/apps/docs/public/coolify-01-new-resource.png
new file mode 100644
index 00000000..1323340d
Binary files /dev/null and b/apps/docs/public/coolify-01-new-resource.png differ
diff --git a/apps/docs/public/coolify-02-services.png b/apps/docs/public/coolify-02-services.png
new file mode 100644
index 00000000..e78cd765
Binary files /dev/null and b/apps/docs/public/coolify-02-services.png differ
diff --git a/apps/docs/public/coolify-03-env-vars.png b/apps/docs/public/coolify-03-env-vars.png
new file mode 100644
index 00000000..efb33142
Binary files /dev/null and b/apps/docs/public/coolify-03-env-vars.png differ
diff --git a/apps/docs/public/coolify-04-image-tag.png b/apps/docs/public/coolify-04-image-tag.png
new file mode 100644
index 00000000..2d4c1629
Binary files /dev/null and b/apps/docs/public/coolify-04-image-tag.png differ
diff --git a/apps/docs/public/coolify-05-dashboard-domain.png b/apps/docs/public/coolify-05-dashboard-domain.png
new file mode 100644
index 00000000..42a88b6b
Binary files /dev/null and b/apps/docs/public/coolify-05-dashboard-domain.png differ
diff --git a/apps/docs/public/coolify-06-deploy-logs.png b/apps/docs/public/coolify-06-deploy-logs.png
new file mode 100644
index 00000000..07428002
Binary files /dev/null and b/apps/docs/public/coolify-06-deploy-logs.png differ
diff --git a/apps/docs/public/railway-01-deploy-template.png b/apps/docs/public/railway-01-deploy-template.png
new file mode 100644
index 00000000..caed08bd
Binary files /dev/null and b/apps/docs/public/railway-01-deploy-template.png differ
diff --git a/apps/docs/public/railway-02-services-overview.png b/apps/docs/public/railway-02-services-overview.png
new file mode 100644
index 00000000..2624e878
Binary files /dev/null and b/apps/docs/public/railway-02-services-overview.png differ
diff --git a/apps/docs/public/railway-03-jobs-smtp.png b/apps/docs/public/railway-03-jobs-smtp.png
new file mode 100644
index 00000000..d50dbedd
Binary files /dev/null and b/apps/docs/public/railway-03-jobs-smtp.png differ
diff --git a/apps/docs/public/railway-04-minio-credentials.png b/apps/docs/public/railway-04-minio-credentials.png
new file mode 100644
index 00000000..a366713e
Binary files /dev/null and b/apps/docs/public/railway-04-minio-credentials.png differ
diff --git a/apps/docs/public/railway-05-api-config.png b/apps/docs/public/railway-05-api-config.png
new file mode 100644
index 00000000..98174230
Binary files /dev/null and b/apps/docs/public/railway-05-api-config.png differ
diff --git a/apps/docs/public/railway-06-api-config-additional.png b/apps/docs/public/railway-06-api-config-additional.png
new file mode 100644
index 00000000..783677b3
Binary files /dev/null and b/apps/docs/public/railway-06-api-config-additional.png differ
diff --git a/apps/docs/public/railway-07-deploying.png b/apps/docs/public/railway-07-deploying.png
new file mode 100644
index 00000000..adab6cf4
Binary files /dev/null and b/apps/docs/public/railway-07-deploying.png differ
diff --git a/apps/docs/public/railway-08-first-boot-crash.png b/apps/docs/public/railway-08-first-boot-crash.png
new file mode 100644
index 00000000..00316dde
Binary files /dev/null and b/apps/docs/public/railway-08-first-boot-crash.png differ
diff --git a/apps/docs/public/railway-09-generate-domain.png b/apps/docs/public/railway-09-generate-domain.png
new file mode 100644
index 00000000..788e8ce2
Binary files /dev/null and b/apps/docs/public/railway-09-generate-domain.png differ
diff --git a/apps/docs/public/railway-10-redeploy-dashboard.png b/apps/docs/public/railway-10-redeploy-dashboard.png
new file mode 100644
index 00000000..9fe7c12e
Binary files /dev/null and b/apps/docs/public/railway-10-redeploy-dashboard.png differ
diff --git a/apps/docs/public/railway-11-redeploy-api.png b/apps/docs/public/railway-11-redeploy-api.png
new file mode 100644
index 00000000..bf166927
Binary files /dev/null and b/apps/docs/public/railway-11-redeploy-api.png differ
diff --git a/apps/docs/public/railway-12-signup.png b/apps/docs/public/railway-12-signup.png
new file mode 100644
index 00000000..0ae728c0
Binary files /dev/null and b/apps/docs/public/railway-12-signup.png differ
diff --git a/docker/coolify/docker-compose.yaml b/docker/coolify/docker-compose.yaml
new file mode 100644
index 00000000..dc0c9d87
--- /dev/null
+++ b/docker/coolify/docker-compose.yaml
@@ -0,0 +1,146 @@
+# documentation: https://classroomio.com/docs/self-hosted/coolify
+# slogan: Open-source LMS to build and teach courses with lessons, quizzes, certificates, student management, and an AI tutor.
+# category: lms
+# tags: lms, education, courses, teaching, e-learning
+# logo: svgs/classroomio.svg
+# port: 3082
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_DB=${POSTGRES_DB:-classroomio}
+ - POSTGRES_USER=$SERVICE_USER_POSTGRES
+ - POSTGRES_PASSWORD=$SERVICE_PASSWORD_POSTGRES
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $SERVICE_USER_POSTGRES -d ${POSTGRES_DB:-classroomio}"]
+ interval: 5s
+ timeout: 5s
+ retries: 15
+
+ redis:
+ image: redis:7-alpine
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redis-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ minio:
+ image: minio/minio:latest
+ command: server /data --console-address ":9001"
+ environment:
+ - SERVICE_FQDN_MINIO_9000
+ - MINIO_ROOT_USER=$SERVICE_USER_MINIO
+ - MINIO_ROOT_PASSWORD=$SERVICE_PASSWORD_MINIO
+ - MINIO_SERVER_URL=$SERVICE_URL_MINIO
+ volumes:
+ - minio-data:/data
+ healthcheck:
+ test: ["CMD", "mc", "ready", "local"]
+ interval: 5s
+ timeout: 20s
+ retries: 10
+
+ minio-init:
+ image: minio/mc:latest
+ depends_on:
+ minio:
+ condition: service_healthy
+ exclude_from_hc: true
+ entrypoint: >
+ /bin/sh -c "
+ set -e;
+ mc alias set local http://minio:9000 $SERVICE_USER_MINIO $SERVICE_PASSWORD_MINIO;
+ mc mb local/videos --ignore-existing;
+ mc mb local/documents --ignore-existing;
+ mc mb local/media --ignore-existing;
+ mc anonymous set download local/media;
+ "
+
+ api:
+ image: classroomio/api:${CIO_VERSION:-latest}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ - NODE_ENV=production
+ - PORT=3081
+ - DATABASE_URL=postgresql://$SERVICE_USER_POSTGRES:$SERVICE_PASSWORD_POSTGRES@postgres:5432/${POSTGRES_DB:-classroomio}
+ - REDIS_URL=redis://redis:6379
+ - PUBLIC_IS_SELFHOSTED=true
+ - BETTER_AUTH_SECRET=$SERVICE_HEX_64_BETTERAUTH
+ - PRIVATE_SERVER_KEY=$SERVICE_HEX_64_PRIVATESERVER
+ - DASHBOARD_ORIGIN=$SERVICE_URL_DASHBOARD
+ - PUBLIC_SERVER_URL=$SERVICE_URL_DASHBOARD
+ - OBJECT_STORAGE_ENDPOINT=http://minio:9000
+ - OBJECT_STORAGE_PUBLIC_ENDPOINT=$SERVICE_URL_MINIO
+ - OBJECT_STORAGE_ACCESS_KEY_ID=$SERVICE_USER_MINIO
+ - OBJECT_STORAGE_SECRET_ACCESS_KEY=$SERVICE_PASSWORD_MINIO
+ - OBJECT_STORAGE_FORCE_PATH_STYLE=true
+ - OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=$SERVICE_URL_MINIO/media
+ - SMTP_HOST=${SMTP_HOST}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_USER=${SMTP_USER}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_SENDER=${SMTP_SENDER}
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 30s
+
+ dashboard:
+ image: classroomio/dashboard:${CIO_VERSION:-latest}
+ depends_on:
+ api:
+ condition: service_healthy
+ environment:
+ - SERVICE_FQDN_DASHBOARD_3082
+ - NODE_ENV=production
+ - PORT=3082
+ - ORIGIN=$SERVICE_URL_DASHBOARD
+ - PUBLIC_IS_SELFHOSTED=true
+ - PRIVATE_SERVER_URL=http://api:3081
+ - PRIVATE_SERVER_KEY=$SERVICE_HEX_64_PRIVATESERVER
+ - CSP_MEDIA_SRC_DOMAINS=$SERVICE_URL_MINIO
+
+ jobs:
+ image: classroomio/jobs:${CIO_VERSION:-latest}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ - NODE_ENV=production
+ - REDIS_URL=redis://redis:6379
+ - DATABASE_URL=postgresql://$SERVICE_USER_POSTGRES:$SERVICE_PASSWORD_POSTGRES@postgres:5432/${POSTGRES_DB:-classroomio}
+ - OBJECT_STORAGE_ENDPOINT=http://minio:9000
+ - OBJECT_STORAGE_PUBLIC_ENDPOINT=$SERVICE_URL_MINIO
+ - OBJECT_STORAGE_ACCESS_KEY_ID=$SERVICE_USER_MINIO
+ - OBJECT_STORAGE_SECRET_ACCESS_KEY=$SERVICE_PASSWORD_MINIO
+ - OBJECT_STORAGE_FORCE_PATH_STYLE=true
+ - OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=$SERVICE_URL_MINIO/media
+ - OBJECT_STORAGE_BUCKET_VIDEOS=videos
+ - OBJECT_STORAGE_BUCKET_DOCUMENTS=documents
+ - OBJECT_STORAGE_BUCKET_MEDIA=media
+ - SMTP_HOST=${SMTP_HOST}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_USER=${SMTP_USER}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_SENDER=${SMTP_SENDER}
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+
+volumes:
+ postgres-data:
+ redis-data:
+ minio-data: