docs(self-hosted): rework Railway + Coolify self-hosting guides with walkthroughs and explicit env vars (#700)

This commit is contained in:
Abdulmuizz Hamzat
2026-07-09 10:01:24 +01:00
committed by GitHub
parent 22a3e6484b
commit bd2585f8fd
24 changed files with 555 additions and 68 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
# - For production, PIN an exact release (e.g. 1.4.2) so a merge to main never auto-breaks you. # - 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. # "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`. # Rollback = set this back to the previous version, then `... pull && up -d`.
CIO_VERSION=latest CIO_VERSION=1
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Required # Required
+256 -34
View File
@@ -1,55 +1,277 @@
--- ---
title: Coolify 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 { 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 */}
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '16px', marginBottom: '16px' }}>
<iframe width="560" height="315" src="https://www.youtube.com/embed/5pwzUn8JBws?si=HcetU4pDYmRbwn4M" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>
## Prerequisites ## Prerequisites
- A running Coolify instance with a connected server. - A running **Coolify instance** with a connected server.
- A domain pointed at that server (Coolify's built-in proxy terminates HTTPS for you). - 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 <Callout title="Two secrets to generate first">
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)
```
</Callout>
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" 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**.
CIO_VERSION=1.4.2 # pin a release (not "latest") so upgrades are deliberate
DASHBOARD_ORIGIN=https://app.yourdomain.com
BETTER_AUTH_SECRET=<openssl rand -hex 32>
PRIVATE_SERVER_KEY=<openssl rand -hex 32> # must be identical in the api and dashboard services
POSTGRES_PASSWORD=<strong password>
# Email (required for signup/reset/invites)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_SENDER=
```
<Callout type="error" title="The stack won't start without strong secrets"> <Steps>
`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.
</Callout>
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. <Step>
### Create a project and add a Docker Compose resource
4. **Object storage.** Recommended: the **bundled MinIO** — enable its profile with 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.
`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.
**Upload size limits** (optional): set `UPLOAD_MAX_*_MB` in Coolify's env for both the `api` and ![Showing list of options to choose from](/coolify-02-services.png)
`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. </Step>
<Step>
### 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.
![Select composer file type](/coolify-01-add-resource.png)
![Coolify showing the parsed ClassroomIO services](/coolify-02-services.png)
</Step>
<Step>
### 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=<openssl rand -hex 32>
PRIVATE_SERVER_KEY=<openssl rand -hex 32> # must be identical in the api and dashboard services
POSTGRES_PASSWORD=<strong password>
# Email (signup verification, password reset, invites)
SMTP_HOST=smtp.yourprovider.com
SMTP_PORT=587
SMTP_USER=<smtp username>
SMTP_PASSWORD=<smtp password>
SMTP_SENDER=ClassroomIO <you@yourdomain.com>
# 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.
<Callout title="How to read the reference: value = required, blank = optional">
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.
</Callout>
![Setting the environment variables in Coolify](/coolify-03-env-vars.png)
</Step>
<Step>
### 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.
<Callout title="Pin a released version">
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.
</Callout>
![Confirming the classroomio image tags on Docker Hub](/coolify-04-image-tag.png)
</Step>
<Step>
### 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.
![Setting the dashboard service domain in Coolify](/coolify-05-dashboard-domain.png)
</Step>
<Step>
### 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.
![Coolify deploy logs showing the stack coming up](/coolify-06-deploy-logs.png)
</Step>
<Step>
### 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.
![The running ClassroomIO dashboard sign-up page](/railway-12-signup.png)
</Step>
</Steps>
## 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.
<Callout title="Value = required, blank = optional">
**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.
</Callout>
```zsh title=".env — a complete working Coolify deployment"
# ── Secrets (generate with: openssl rand -hex 32) ──
BETTER_AUTH_SECRET=<openssl rand -hex 32>
PRIVATE_SERVER_KEY=<openssl rand -hex 32> # 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=<strong 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=<storage access key>
MINIO_ROOT_PASSWORD=<storage secret>
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=<same as MINIO_ROOT_USER>
OBJECT_STORAGE_SECRET_ACCESS_KEY=<same as MINIO_ROOT_PASSWORD>
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 username>
SMTP_PASSWORD=<smtp password>
SMTP_SENDER=ClassroomIO <you@yourdomain.com>
# ── 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. |
<Callout type="warn" title="If the stack won't start at all">
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.
</Callout>
### 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.
<Callout type="warn" title="MinIO is your durability boundary">
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.
</Callout>
### 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 ## Upgrades & rollback
@@ -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: 2. Edit `.env`. At minimum set `CIO_VERSION` (pin a release), `DASHBOARD_ORIGIN`, the auth secrets, and SMTP:
```zsh title=".env" ```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 DASHBOARD_ORIGIN=https://app.yourdomain.com
BETTER_AUTH_SECRET=<openssl rand -hex 32> BETTER_AUTH_SECRET=<openssl rand -hex 32>
PRIVATE_SERVER_KEY=<openssl rand -hex 32> # must be identical in the api and dashboard PRIVATE_SERVER_KEY=<openssl rand -hex 32> # 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: 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. - 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.4.2`, `1.4`, `1`) are the immutable, pinned releases. - `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: **Pre-built images path** — bump `CIO_VERSION` in `.env`, then re-pull and recreate:
+149 -30
View File
@@ -4,14 +4,148 @@ description: Deploy ClassroomIO on Railway with the one-click template.
--- ---
import { Callout } from 'fumadocs-ui/components/callout'; 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. 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.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/classroomio-1?referralCode=8OyIj5) [![Deploy on Railway](https://railway.com/button.svg)](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.
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '16px', marginBottom: '16px' }}>
<iframe width="560" height="315" src="https://www.youtube.com/embed/JQZ1ZikLM20?si=AAdI2Muyb2GcetiS" title="YouTube video player" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerPolicy="strict-origin-when-cross-origin" allowFullScreen></iframe>
</div>
## 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.
<Callout title="Two secrets to generate first">
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)
```
</Callout>
## 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**.
<Steps>
<Step>
### 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.
![Railway template page for ClassroomIO after clicking Deploy](/railway-01-deploy-template.png)
</Step>
<Step>
### 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`.
![The Railway template listing all preconfigured ClassroomIO services](/railway-02-services-overview.png)
</Step>
<Step>
### 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`
![Entering the SMTP variables on the cio-jobs service in the template](/railway-03-jobs-smtp.png)
</Step>
<Step>
### 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`
![Configuring MINIO_SERVER_URL and credentials on the cio-minio service](/railway-04-minio-credentials.png)
</Step>
<Step>
### 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).
![Configuring the cio-api service variables in the template](/railway-05-api-config.png)
![Additional cio-api configuration options](/railway-06-api-config-additional.png)
</Step>
<Step>
### Deploy
Click **Deploy**. Railway begins building and bringing the services online — Redis, Postgres, and the rest come up first.
![Railway deploying the ClassroomIO services after clicking Deploy](/railway-07-deploying.png)
</Step>
<Step>
### First boot may crash — this is expected
<Callout type="warn" title="Only if you use the Railway-generated subdomain">
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.
</Callout>
![cio-api crashing on first boot before the dashboard domain exists](/railway-08-first-boot-crash.png)
</Step>
<Step>
### Generate the dashboard domain
Open **cio-dashboard → Settings → Networking** and click **Generate Domain**. Railway assigns the public domain the API needs.
![cio-dashboard Settings and Networking with the Generate Domain button](/railway-09-generate-domain.png)
</Step>
<Step>
### 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.
![Redeploying the cio-dashboard](/railway-10-redeploy-dashboard.png)
![Redeploying the cio-api](/railway-11-redeploy-api.png)
</Step>
<Step>
### Open the app and sign up
Open the generated dashboard URL in a **new browser tab** and **sign up** to create your first account.
![The running ClassroomIO dashboard sign-up page on the Railway domain](/railway-12-signup.png)
</Step>
</Steps>
## 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" ```zsh title="env"
DATABASE_URL="${{db.DATABASE_URL}}" 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. 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" ```zsh title="env"
# RAILWAY_PUBLIC_DOMAIN here = this dashboard service's own public domain. # 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). **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: 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. `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.
</Callout> </Callout>
## 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 `cio-minio` ships **in the template** — its `/data` volume is already attached and the `videos`,
the Docker image `minio/minio:latest`: `documents`, and `media` buckets are already seeded. You only set its credentials during
configuration:
- **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.
```zsh title="env" ```zsh title="env"
MINIO_ROOT_USER=<strong user> MINIO_ROOT_USER=<strong user>
MINIO_ROOT_PASSWORD=<strong password> MINIO_ROOT_PASSWORD=<strong password>
# Optional (silences console redirect warnings behind the proxy): # Recommended (not required — ClassroomIO reaches storage via OBJECT_STORAGE_* below;
MINIO_SERVER_URL=https://storage.yourdomain.com # 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` 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. (browser-facing presigned downloads and public images) — already wired in the env blocks above.
`OBJECT_STORAGE_FORCE_PATH_STYLE=true` is required for MinIO. `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)
```
<Callout type="warn" title="MinIO is your durability boundary"> <Callout type="warn" title="MinIO is your durability boundary">
Single-node MinIO has no redundancy — the Railway Volume is the only thing protecting your 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** 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 instead (R2 has no egress fees): point the same `OBJECT_STORAGE_*` vars at the provider. See the
the cio-minio service. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2. [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
</Callout> </Callout>
## SMTP Setup ### SMTP Setup
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider 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. SSO requires a license key. Set `LICENSE_KEY` in your API environment. [Book a call](http://cal.com/classroomio/enterprise) to get a license.
Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+146
View File
@@ -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: