Merge branch 'main' of https://github.com/classroomio/classroomio
This commit is contained in:
@@ -6,7 +6,6 @@ PUBLIC_SERVER_URL=""
|
||||
TRUSTED_ORIGINS=""
|
||||
BETTER_AUTH_SECRET=""
|
||||
|
||||
AUTH_BEARER_TOKEN=""
|
||||
AUTH_COOKIE_DOMAIN=""
|
||||
|
||||
# Email provider — used by the API only for auth-flow emails (forgot
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
FROM node:slim AS app
|
||||
|
||||
# Add build arguments
|
||||
ARG CLOUDFLARE_BUCKET_DOMAIN
|
||||
ARG CLOUDFLARE_ACCESS_KEY
|
||||
ARG CLOUDFLARE_SECRET_ACCESS_KEY
|
||||
ARG CLOUDFLARE_ACCOUNT_ID
|
||||
ARG CLOUDFLARE_BUCKET_ID
|
||||
ARG SMTP_HOST
|
||||
ARG SMTP_USER
|
||||
ARG SMTP_PASSWORD
|
||||
ARG SMTP_PORT
|
||||
ARG SMTP_SENDER
|
||||
ARG ZOHO_TOKEN
|
||||
ARG CLOUDFLARE_RENDERING_API_KEY
|
||||
ARG OPENAPI_URL
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm and healthcheck tools
|
||||
RUN npm install -g pnpm && \
|
||||
apt-get update && \
|
||||
apt-get install -y curl wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install chrome stable from sources, then remove it. Why? The
|
||||
# npm install of `puppeteer` brings its own bundle chromium build,
|
||||
# and puppeteer releases are only guaranteed to work with that version.
|
||||
#
|
||||
# But the bundled chromium implicitly needs a bunch of shared libs on
|
||||
# the host. It's a little tedious to find and maintain that list; but
|
||||
# the official apt distro of `google-chrome-stable` should bring the right
|
||||
# set along. So do that, but immediately uninstall (to free up layer space).
|
||||
#
|
||||
# This is a little brittle, since the puppeteer chrome could in theory diverge
|
||||
# from the official apt chrome's shared lib deps. But it works..
|
||||
# RUN apt-get update \
|
||||
# && apt-get install curl gnupg -y \
|
||||
# && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
|
||||
# && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
|
||||
# && apt-get update \
|
||||
# && apt-get install google-chrome-stable -y --no-install-recommends \
|
||||
# && apt-get remove google-chrome-stable -y \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add emoji fonts.
|
||||
# Source: https://gist.github.com/win0err/9d8c7f0feabdfe8a4c9787b02c79ac51
|
||||
# RUN mkdir ~/.fonts/ && \
|
||||
# wget https://github.com/samuelngs/apple-emoji-linux/releases/download/ios-15.4/AppleColorEmoji.ttf -O ~/.fonts/AppleColorEmoji.ttf
|
||||
|
||||
COPY ./package.json /app
|
||||
RUN pnpm install
|
||||
COPY . /app
|
||||
|
||||
# Set environment variables from build args
|
||||
ENV NODE_ENV=production \
|
||||
CLOUDFLARE_BUCKET_DOMAIN=$CLOUDFLARE_BUCKET_DOMAIN \
|
||||
CLOUDFLARE_ACCESS_KEY=$CLOUDFLARE_ACCESS_KEY \
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
|
||||
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
|
||||
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
|
||||
CLOUDFLARE_RENDERING_API_KEY=$CLOUDFLARE_RENDERING_API_KEY \
|
||||
SMTP_HOST=$SMTP_HOST \
|
||||
SMTP_USER=$SMTP_USER \
|
||||
SMTP_PASSWORD=$SMTP_PASSWORD \
|
||||
SMTP_PORT=$SMTP_PORT \
|
||||
SMTP_SENDER=$SMTP_SENDER \
|
||||
ZOHO_TOKEN=$ZOHO_TOKEN \
|
||||
OPENAPI_URL=$OPENAPI_URL \
|
||||
PORT=3002
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
# Add healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3002 || exit 1
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -1,16 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -34,43 +26,6 @@
|
||||
}: Props = $props();
|
||||
|
||||
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
}
|
||||
|
||||
function handleShareCourse() {
|
||||
redirect(`/courses/${id}/settings#share`);
|
||||
}
|
||||
|
||||
function handleInvite() {
|
||||
redirect(`/courses/${id}/people?add=true`);
|
||||
}
|
||||
|
||||
function handleDeleteCourse() {
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
function handleCloneCourse() {
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenPublicCourse() {
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lmsPublicQuickOnly ? showPublicCourseLinks : true}
|
||||
@@ -84,41 +39,7 @@
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if lmsPublicQuickOnly}
|
||||
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<CourseContextMenuContent {id} {title} {description} {isPublished} {courseType} {slug} {lmsPublicQuickOnly} />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isPublished?: boolean;
|
||||
courseType?: string | null;
|
||||
slug?: string;
|
||||
/** Compact menu for LMS course cards */
|
||||
lmsPublicQuickOnly?: boolean;
|
||||
/** Include "View as student" (course header menu) */
|
||||
includeViewAsStudent?: boolean;
|
||||
onViewAsStudent?: () => void;
|
||||
/** List view includes an explicit Open action */
|
||||
includeOpen?: boolean;
|
||||
/** Hide org management actions (clone, share, invite, delete) */
|
||||
hideOrgActions?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
isPublished = false,
|
||||
courseType = null,
|
||||
slug = '',
|
||||
lmsPublicQuickOnly = false,
|
||||
includeViewAsStudent = false,
|
||||
onViewAsStudent,
|
||||
includeOpen = false,
|
||||
hideOrgActions = false
|
||||
}: Props = $props();
|
||||
|
||||
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
}
|
||||
|
||||
function handleShareCourse() {
|
||||
redirect(`/courses/${id}/settings#share`);
|
||||
}
|
||||
|
||||
function handleInvite() {
|
||||
redirect(`/courses/${id}/people?add=true`);
|
||||
}
|
||||
|
||||
function handlePublishCourse() {
|
||||
redirect(`/courses/${id}/settings#publish`);
|
||||
}
|
||||
|
||||
function handleDeleteCourse() {
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
function handleCloneCourse() {
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenCourse() {
|
||||
redirect(`/courses/${id}`);
|
||||
}
|
||||
|
||||
function handleViewCourseSite() {
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lmsPublicQuickOnly}
|
||||
<DropdownMenu.Item onclick={handleViewCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.view_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#if includeViewAsStudent}
|
||||
<DropdownMenu.Item onclick={() => onViewAsStudent?.()}>
|
||||
<EyeIcon size={16} class="mr-2" />
|
||||
{$t('course.header.view_as_student')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
|
||||
{#if isPublished}
|
||||
<DropdownMenu.Item onclick={handleViewCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.view_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if !hideOrgActions || includeOpen}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={handlePublishCourse}>
|
||||
{$t('courses.course_card.context_menu.publish_course')}
|
||||
</DropdownMenu.Item>
|
||||
{#if !hideOrgActions || includeOpen}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if includeOpen}
|
||||
<DropdownMenu.Item onclick={handleOpenCourse}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if !hideOrgActions}
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import * as ButtonGroup from '@cio/ui/base/button-group';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Waves } from '@cio/ui/custom/animation';
|
||||
import { page } from '$app/state';
|
||||
@@ -12,23 +15,46 @@
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { getActiveCourseNavKey } from '$features/course/utils/functions';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||
import ViewAsStudentModal from './view-as-student-modal.svelte';
|
||||
import ViewCourseSiteUnpublishedModal from './view-course-site-unpublished-modal.svelte';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
const showCoursePublishBadge = $derived(!$isStudentExperience);
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const activeNavKey = $derived(getActiveCourseNavKey(page.url.pathname, courseApi.course?.id ?? ''));
|
||||
const isPublished = $derived(courseApi.course?.isPublished ?? false);
|
||||
|
||||
let viewAsStudentOpen = $state(false);
|
||||
let viewCourseSiteUnpublishedOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!siteName) return;
|
||||
|
||||
setupProgressApi.fetchSetupProgress(siteName);
|
||||
});
|
||||
|
||||
function handleViewCourseSite() {
|
||||
const course = courseApi.course;
|
||||
if (!course?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPublished) {
|
||||
viewCourseSiteUnpublishedOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
openCoursePreview({
|
||||
courseId: course.id,
|
||||
courseSlug: course.slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -52,7 +78,7 @@
|
||||
{/if}
|
||||
|
||||
{#if showCoursePublishBadge}
|
||||
<CoursePublishBadge isPublished={courseApi.course?.isPublished ?? false} />
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,16 +104,47 @@
|
||||
</Button>
|
||||
|
||||
{#if !$isStudentExperience}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (viewAsStudentOpen = true)}
|
||||
disabled={!courseApi.course?.id}
|
||||
aria-label={$t('course.header.view_as_student')}
|
||||
>
|
||||
<EyeIcon size={14} />
|
||||
<span class="hidden sm:inline">{$t('course.header.view_as_student')}</span>
|
||||
</Button>
|
||||
<ButtonGroup.Root>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={handleViewCourseSite}
|
||||
disabled={!courseApi.course?.id}
|
||||
aria-label={$t('course.header.view_course_site')}
|
||||
>
|
||||
<ExternalLinkIcon size={14} />
|
||||
<span class="hidden sm:inline">{$t('course.header.view_course_site')}</span>
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={$t('courses.course_card.actions_menu_aria')}
|
||||
disabled={!courseApi.course?.id}
|
||||
>
|
||||
<EllipsisVerticalIcon size={14} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if courseApi.course}
|
||||
<CourseContextMenuContent
|
||||
id={courseApi.course.id}
|
||||
title={courseApi.course.title}
|
||||
description={courseApi.course.description}
|
||||
isPublished={courseApi.course.isPublished ?? false}
|
||||
courseType={courseApi.course.type}
|
||||
slug={courseApi.course.slug ?? ''}
|
||||
includeViewAsStudent={true}
|
||||
onViewAsStudent={() => (viewAsStudentOpen = true)}
|
||||
/>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ButtonGroup.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -98,3 +155,5 @@
|
||||
courseSlug={courseApi.course?.slug}
|
||||
currentOrgDomain={$currentOrgDomain}
|
||||
/>
|
||||
|
||||
<ViewCourseSiteUnpublishedModal bind:open={viewCourseSiteUnpublishedOpen} currentOrgDomain={$currentOrgDomain} />
|
||||
|
||||
@@ -10,14 +10,10 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { Image } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
|
||||
import CourseContentIcon from './course-content-icon.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
@@ -133,30 +129,6 @@
|
||||
}
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleOpen(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleClone(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleDelete(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
@@ -317,37 +289,27 @@
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
})}
|
||||
>
|
||||
<ExternalLinkIcon class="mr-2 size-4" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon class="mr-2 size-4" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleOpen}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleClone}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{#if isLMS}
|
||||
<CourseContextMenuContent
|
||||
{id}
|
||||
{title}
|
||||
{description}
|
||||
{isPublished}
|
||||
courseType={type}
|
||||
{slug}
|
||||
lmsPublicQuickOnly={true}
|
||||
/>
|
||||
{:else}
|
||||
<CourseContextMenuContent
|
||||
{id}
|
||||
{title}
|
||||
{description}
|
||||
{isPublished}
|
||||
courseType={type}
|
||||
{slug}
|
||||
includeOpen={true}
|
||||
hideOrgActions={false}
|
||||
/>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { publishCourse } from '$features/course/utils/publish-course';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), currentOrgDomain = '' }: Props = $props();
|
||||
|
||||
let isPublishing = $state(false);
|
||||
|
||||
async function handlePublishCourse() {
|
||||
const course = courseApi.course;
|
||||
if (!course?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPublishing = true;
|
||||
|
||||
const published = await publishCourse(course);
|
||||
isPublishing = false;
|
||||
|
||||
if (!published) {
|
||||
return;
|
||||
}
|
||||
|
||||
open = false;
|
||||
|
||||
openCoursePreview({
|
||||
courseId: course.id,
|
||||
courseSlug: courseApi.course?.slug,
|
||||
currentOrgDomain
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !isPublishing) {
|
||||
open = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-[calc(100%-2rem)] max-w-md! p-4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.view_course_site.unpublished_title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.view_course_site.unpublished_description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isPublishing}>
|
||||
{$t('course.view_course_site.cancel')}
|
||||
</Button>
|
||||
<Button variant="default" onclick={handlePublishCourse} loading={isPublishing} disabled={!courseApi.course?.id}>
|
||||
{$t('course.view_course_site.publish_course')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -794,7 +794,7 @@
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Set id="publish">
|
||||
<Field.Legend>{$t('course.navItem.settings.publish')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.determines')}</Field.Description>
|
||||
<Field.Field orientation="horizontal">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { isObject } from '$lib/utils/functions/isObject';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
|
||||
export async function publishCourse(course: Course): Promise<boolean> {
|
||||
if (!course?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let slug = course.slug?.trim() ? course.slug : undefined;
|
||||
if (!slug) {
|
||||
slug = generateSlug(course.title, { appendTimestamp: true });
|
||||
}
|
||||
|
||||
const metadataPayload = {
|
||||
...(isObject(course.metadata) ? course.metadata : {}),
|
||||
allowNewStudent: true
|
||||
};
|
||||
|
||||
const result = await courseApi.update(
|
||||
course.id,
|
||||
{
|
||||
isPublished: true,
|
||||
slug,
|
||||
metadata: metadataPayload
|
||||
},
|
||||
{ showSuccessToast: true }
|
||||
);
|
||||
|
||||
return !!result;
|
||||
}
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Se som studerende"
|
||||
"view_as_student": "Se som studerende",
|
||||
"view_course_site": "Se kursusside"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Udgiv dit kursus",
|
||||
"unpublished_description": "Du bør udgive dette kursus, før du kan se sitet",
|
||||
"cancel": "Annuller",
|
||||
"publish_course": "Udgiv kursus"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Tilbage til kurset",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Slet",
|
||||
"open": "Åbn",
|
||||
"open_public_course": "Åbn offentligt kursus",
|
||||
"view_course_site": "Se kursusside",
|
||||
"publish_course": "Udgiv kursus",
|
||||
"copy_course_url": "Kopiér kursus-URL"
|
||||
},
|
||||
"due_date": "Forfalder",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Als Student anzeigen"
|
||||
"view_as_student": "Als Student anzeigen",
|
||||
"view_course_site": "Kursseite anzeigen"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Kurs veröffentlichen",
|
||||
"unpublished_description": "Sie sollten diesen Kurs veröffentlichen, bevor Sie die Seite ansehen können",
|
||||
"cancel": "Abbrechen",
|
||||
"publish_course": "Kurs veröffentlichen"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Zurück zum Kurs",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Löschen",
|
||||
"open": "Offen",
|
||||
"open_public_course": "Öffentlichen Kurs öffnen",
|
||||
"view_course_site": "Kursseite anzeigen",
|
||||
"publish_course": "Kurs veröffentlichen",
|
||||
"copy_course_url": "Kurs-URL kopieren"
|
||||
},
|
||||
"due_date": "Fällig",
|
||||
|
||||
@@ -1970,6 +1970,8 @@
|
||||
"invite": "Invite",
|
||||
"delete": "Delete",
|
||||
"open_public_course": "Open public course",
|
||||
"view_course_site": "View course site",
|
||||
"publish_course": "Publish course",
|
||||
"copy_course_url": "Copy course URL"
|
||||
},
|
||||
"error_message": "An error occurred.",
|
||||
@@ -2147,7 +2149,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "View as student"
|
||||
"view_as_student": "View as student",
|
||||
"view_course_site": "View Course Site"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publish your course",
|
||||
"unpublished_description": "You should publish this course before you can see the site",
|
||||
"cancel": "Cancel",
|
||||
"publish_course": "Publish Course"
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "This will redirect you to the Student LMS.",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Ver como estudiante"
|
||||
"view_as_student": "Ver como estudiante",
|
||||
"view_course_site": "Ver sitio del curso"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publica tu curso",
|
||||
"unpublished_description": "Debes publicar este curso antes de poder ver el sitio",
|
||||
"cancel": "Cancelar",
|
||||
"publish_course": "Publicar curso"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "volver al curso",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Eliminar",
|
||||
"open": "Abierto",
|
||||
"open_public_course": "Abrir curso público",
|
||||
"view_course_site": "Ver sitio del curso",
|
||||
"publish_course": "Publicar curso",
|
||||
"copy_course_url": "Copiar URL del curso"
|
||||
},
|
||||
"due_date": "debido",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Afficher en tant qu'étudiant"
|
||||
"view_as_student": "Afficher en tant qu'étudiant",
|
||||
"view_course_site": "Voir le site du cours"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publiez votre cours",
|
||||
"unpublished_description": "Vous devez publier ce cours avant de pouvoir voir le site",
|
||||
"cancel": "Annuler",
|
||||
"publish_course": "Publier le cours"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Retour au cours",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Supprimer",
|
||||
"open": "Ouvert",
|
||||
"open_public_course": "Ouvrir le cours public",
|
||||
"view_course_site": "Voir le site du cours",
|
||||
"publish_course": "Publier le cours",
|
||||
"copy_course_url": "Copier l’URL du cours"
|
||||
},
|
||||
"due_date": "Due",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "विद्यार्थी के रूप में देखें"
|
||||
"view_as_student": "विद्यार्थी के रूप में देखें",
|
||||
"view_course_site": "कोर्स साइट देखें"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "अपना कोर्स प्रकाशित करें",
|
||||
"unpublished_description": "साइट देखने से पहले आपको इस कोर्स को प्रकाशित करना चाहिए",
|
||||
"cancel": "रद्द करें",
|
||||
"publish_course": "कोर्स प्रकाशित करें"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "हटाएँ",
|
||||
"open": "खुला",
|
||||
"open_public_course": "सार्वजनिक कोर्स खोलें",
|
||||
"view_course_site": "कोर्स साइट देखें",
|
||||
"publish_course": "कोर्स प्रकाशित करें",
|
||||
"copy_course_url": "कोर्स URL कॉपी करें"
|
||||
},
|
||||
"due_date": "देय",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Wyświetl jako student"
|
||||
"view_as_student": "Wyświetl jako student",
|
||||
"view_course_site": "Zobacz stronę kursu"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Opublikuj swój kurs",
|
||||
"unpublished_description": "Powinieneś opublikować ten kurs, zanim zobaczysz stronę",
|
||||
"cancel": "Anuluj",
|
||||
"publish_course": "Opublikuj kurs"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Powrót na kurs",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Usuń",
|
||||
"open": "Otwórz",
|
||||
"open_public_course": "Otwórz publiczny kurs",
|
||||
"view_course_site": "Zobacz stronę kursu",
|
||||
"publish_course": "Opublikuj kurs",
|
||||
"copy_course_url": "Kopiuj adres URL kursu"
|
||||
},
|
||||
"due_date": "Termin",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Ver como aluno"
|
||||
"view_as_student": "Ver como aluno",
|
||||
"view_course_site": "Ver site do curso"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publique seu curso",
|
||||
"unpublished_description": "Você deve publicar este curso antes de poder ver o site",
|
||||
"cancel": "Cancelar",
|
||||
"publish_course": "Publicar curso"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "De volta ao curso",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Excluir",
|
||||
"open": "Abrir",
|
||||
"open_public_course": "Abrir curso público",
|
||||
"view_course_site": "Ver site do curso",
|
||||
"publish_course": "Publicar curso",
|
||||
"copy_course_url": "Copiar URL do curso"
|
||||
},
|
||||
"due_date": "Devido",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Посмотреть как студент"
|
||||
"view_as_student": "Посмотреть как студент",
|
||||
"view_course_site": "Посмотреть сайт курса"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Опубликуйте курс",
|
||||
"unpublished_description": "Перед просмотром сайта необходимо опубликовать этот курс",
|
||||
"cancel": "Отмена",
|
||||
"publish_course": "Опубликовать курс"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Вернуться к курсу",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Удалить",
|
||||
"open": "Открыть",
|
||||
"open_public_course": "Открыть публичный курс",
|
||||
"view_course_site": "Посмотреть сайт курса",
|
||||
"publish_course": "Опубликовать курс",
|
||||
"copy_course_url": "Копировать URL курса"
|
||||
},
|
||||
"due_date": "Срок погашения",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Xem với tư cách sinh viên"
|
||||
"view_as_student": "Xem với tư cách sinh viên",
|
||||
"view_course_site": "Xem trang khóa học"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Xuất bản khóa học của bạn",
|
||||
"unpublished_description": "Bạn nên xuất bản khóa học này trước khi có thể xem trang web",
|
||||
"cancel": "Hủy",
|
||||
"publish_course": "Xuất bản khóa học"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Quay lại khóa học",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Xóa",
|
||||
"open": "Mở",
|
||||
"open_public_course": "Mở khóa học công khai",
|
||||
"view_course_site": "Xem trang khóa học",
|
||||
"publish_course": "Xuất bản khóa học",
|
||||
"copy_course_url": "Sao chép URL khóa học"
|
||||
},
|
||||
"due_date": "Đến hạn",
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Cloudflare Setup
|
||||
description: Guide to setting up Cloudflare for ClassroomIO.
|
||||
---
|
||||
|
||||
In this guide, you will learn how to get your Cloudflare environment variables and configure R2 buckets. ClassroomIO supports Cloudflare R2 for video uploads, document uploads, and media (images and video thumbnails). For on-premise or data-residency deployments, you can use [MinIO](/docs/quickstart/self-hosting#option-b-minio-s3-compatible-on-premise) instead.
|
||||
In this guide, you will learn how to get your Cloudflare environment variables and configure R2 buckets. ClassroomIO supports Cloudflare R2 for video uploads, document uploads, and media (images and video thumbnails). For on-premise or data-residency deployments, you can use [MinIO](/docs/self-hosted/docker#optional--object-storage) instead.
|
||||
|
||||
## R2 buckets overview
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
"roles-and-permissions",
|
||||
|
||||
"---Contributor guides---",
|
||||
"contributor-guides"
|
||||
"contributor-guides",
|
||||
|
||||
"---Self Hosting---",
|
||||
"self-hosted"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"signup": "Sign Up",
|
||||
"onboarding": "Onboarding",
|
||||
"self-hosting": "Self-hosting"
|
||||
"onboarding": "Onboarding"
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
---
|
||||
title: Self-hosting ClassroomIO
|
||||
description: Guide to self-hosting ClassroomIO on your own servers.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
This guide covers two deployment paths: **Docker** (recommended) and **Railway**.
|
||||
|
||||

|
||||
|
||||
## Docker (Recommended)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- `git` installed
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```zsh title="Terminal"
|
||||
git clone https://github.com/classroomio/classroomio.git
|
||||
cd classroomio
|
||||
```
|
||||
|
||||
2. Copy the env template and edit it:
|
||||
|
||||
```zsh title="Terminal"
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and set `DASHBOARD_ORIGIN` and `BETTER_AUTH_SECRET` for your domain. For local development the defaults work as-is.
|
||||
|
||||
3. Start the full stack:
|
||||
|
||||
```zsh title="Terminal"
|
||||
./run-docker-full-stack.sh
|
||||
```
|
||||
|
||||
The script builds and starts Postgres, Redis, MinIO (object storage), the API, and the dashboard. It auto-generates secure values for `AUTH_BEARER_TOKEN` and `PRIVATE_SERVER_KEY` when they are missing.
|
||||
|
||||
4. Verify:
|
||||
|
||||
```zsh title="Terminal"
|
||||
curl -sS http://localhost:3081/ # API — returns JSON
|
||||
curl -I http://localhost:3082/ # Dashboard — returns 200
|
||||
```
|
||||
|
||||
- Dashboard: http://localhost:3082
|
||||
- API: http://localhost:3081
|
||||
- MinIO Console: http://localhost:9001 (default login: `minioadmin` / `minioadmin`)
|
||||
|
||||
### One `.env`, One Command
|
||||
|
||||
Docker self-hosting uses a single root `.env` file. Every `docker compose` command reads it via `--env-file .env` — you never need separate env files per service. See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the full template.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Required
|
||||
|
||||
| Variable | Description | Local Default |
|
||||
|----------|-------------|---------------|
|
||||
| `POSTGRES_DB` | Database name | `classroomio` |
|
||||
| `POSTGRES_USER` | Database user | `postgres` |
|
||||
| `POSTGRES_PASSWORD` | Database password | `postgres` |
|
||||
| `BETTER_AUTH_SECRET` | Auth session signing secret | — |
|
||||
| `AUTH_BEARER_TOKEN` | API bearer token | Auto-generated |
|
||||
| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key (same in both) | Auto-generated |
|
||||
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
|
||||
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
|
||||
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
|
||||
|
||||
#### Optional — Direct API Access
|
||||
|
||||
Dashboard browser requests use same-origin `/proxy/*` and `/api/auth/*` paths, then the dashboard forwards them to `PRIVATE_SERVER_URL`. Set these only when browsers or third-party clients need to call the API origin directly.
|
||||
|
||||
| Variable | Description | Local Default |
|
||||
|----------|-------------|---------------|
|
||||
| `PUBLIC_SERVER_URL` | Canonical public API URL / direct API fallback | `http://localhost:3081` |
|
||||
| `TRUSTED_ORIGINS` | Comma-separated CORS origins for direct browser-to-API calls | — |
|
||||
|
||||
#### CSP Domains (Runtime)
|
||||
|
||||
CSP domain variables are **runtime** — set them in your `.env` or `docker-compose.yaml` `environment:` section. No image rebuild is needed when changing them.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
|
||||
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
|
||||
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
|
||||
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
|
||||
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
|
||||
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
|
||||
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
|
||||
|
||||
<Callout type="warn" title="CSP Domains">
|
||||
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, video, and font domains. The API does not need to be added for normal dashboard traffic because requests are proxied same-origin. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
|
||||
</Callout>
|
||||
|
||||
#### Optional — Email
|
||||
|
||||
Configure either SMTP or Zoho. Without email config, the app works but cannot send emails.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SENDER` | SMTP server |
|
||||
| `ZOHO_TOKEN` | Zoho/ZeptoMail token (alternative to SMTP) |
|
||||
|
||||
#### Optional — Object Storage
|
||||
|
||||
MinIO is included by default. The startup script auto-configures these — you only need to change them if using custom credentials or an external S3-compatible service.
|
||||
|
||||
| Variable | Default |
|
||||
|----------|---------|
|
||||
| `MINIO_ROOT_USER` | `minioadmin` |
|
||||
| `MINIO_ROOT_PASSWORD` | `minioadmin` |
|
||||
| `OBJECT_STORAGE_ENDPOINT` | `http://minio:9000` |
|
||||
| `OBJECT_STORAGE_PUBLIC_ENDPOINT` | `http://localhost:9000` |
|
||||
| `OBJECT_STORAGE_ACCESS_KEY_ID` | `minioadmin` |
|
||||
| `OBJECT_STORAGE_SECRET_ACCESS_KEY` | `minioadmin` |
|
||||
| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` |
|
||||
| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` |
|
||||
|
||||
Use `./run-docker-full-stack.sh --no-minio` to exclude MinIO if you do not need uploads.
|
||||
|
||||
<Callout type="error" title="Change default credentials">
|
||||
Do not use `minioadmin` / `minioadmin` in production.
|
||||
</Callout>
|
||||
|
||||
#### Optional — Integrations
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth |
|
||||
| `PRIVATE_APP_HOST`, `PRIVATE_APP_SUBDOMAINS` | Multi-subdomain routing |
|
||||
| `UNSPLASH_API_KEY` | Unsplash image search |
|
||||
| `LICENSE_KEY` | Enterprise features (SSO, token-auth, no-tracking) |
|
||||
| `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```zsh title="Terminal"
|
||||
# Start full stack (builds images, starts all services including MinIO)
|
||||
./run-docker-full-stack.sh
|
||||
|
||||
# Start without rebuilding images
|
||||
./run-docker-full-stack.sh --no-build
|
||||
|
||||
# Start without MinIO (no file uploads)
|
||||
./run-docker-full-stack.sh --no-minio
|
||||
|
||||
# Service status
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml ps
|
||||
|
||||
# Stream logs
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml logs -f api dashboard
|
||||
|
||||
# Rebuild one service
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml up -d --build api
|
||||
|
||||
# Restart after env change (no rebuild needed)
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml restart api dashboard
|
||||
|
||||
# Run DB seed
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml run --rm --entrypoint sh api -c "pnpm --filter @cio/db db:setup -- --seed"
|
||||
|
||||
# Stop
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml down
|
||||
|
||||
# Stop and delete all data (database, redis, minio volumes)
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml down -v
|
||||
```
|
||||
|
||||
See [docker/docs/SELF_HOST.md](https://github.com/classroomio/classroomio/blob/main/docker/docs/SELF_HOST.md) for the full Docker reference including troubleshooting.
|
||||
|
||||
### Troubleshooting: "No space left on device"
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker system df # check usage
|
||||
docker builder prune -f # free build cache
|
||||
docker image prune -a -f # free unused images
|
||||
docker container prune -f # free stopped containers
|
||||
```
|
||||
|
||||
<Callout type="warn" title="Volume prune deletes data">
|
||||
Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes.
|
||||
</Callout>
|
||||
|
||||
Then restart: `./run-docker-full-stack.sh`
|
||||
|
||||
---
|
||||
|
||||
## Railway
|
||||
|
||||
The quickest hosted deployment is the [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5).
|
||||
|
||||
[](https://railway.app/template/dl9RG7?referralCode=8OyIj5)
|
||||
|
||||
### cio-api
|
||||
|
||||
```zsh title="env"
|
||||
DATABASE_URL="${{db.DATABASE_URL}}"
|
||||
REDIS_URL="${{Redis.REDIS_URL}}"
|
||||
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
|
||||
AUTH_BEARER_TOKEN="<generate-with-openssl-rand-hex-32>"
|
||||
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # must be identical in cio-api and cio-dashboard
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
|
||||
# Dashboard URL for invite/email links (e.g. https://app.yourdomain.com)
|
||||
DASHBOARD_ORIGIN="https://app.yourdomain.com"
|
||||
|
||||
# Optional direct public API URL. Normal dashboard auth/API calls use the
|
||||
# dashboard proxy and do not require API/dashboard cookie-domain matching.
|
||||
PUBLIC_SERVER_URL="https://api.yourdomain.com"
|
||||
TRUSTED_ORIGINS=
|
||||
|
||||
# Email (configure one)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
|
||||
# Object storage — Cloudflare R2 (for hosted deployments)
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_ACCESS_KEY=
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY=
|
||||
CLOUDFLARE_BUCKET_DOMAIN=
|
||||
CLOUDFLARE_IMAGE_BUCKET_DOMAIN=
|
||||
```
|
||||
|
||||
Optional: add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` for OAuth, `UNSPLASH_API_KEY` for image search, or `LICENSE_KEY` for enterprise features.
|
||||
|
||||
### cio-dashboard
|
||||
|
||||
```zsh title="env"
|
||||
ALLOWED_EXTERNAL_DOMAINS="https://*.yourdomain.com,https://fonts.googleapis.com"
|
||||
PRIVATE_APP_HOST="https://app.yourdomain.com"
|
||||
PRIVATE_SERVER_KEY="same-key-for-api-and-dashboard"
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
PRIVATE_SERVER_URL="http://${{api.RAILWAY_PRIVATE_DOMAIN}}:8080"
|
||||
ORIGIN="https://app.yourdomain.com"
|
||||
```
|
||||
|
||||
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
|
||||
|
||||
<Callout title="PRIVATE_SERVER_KEY">
|
||||
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API.
|
||||
</Callout>
|
||||
|
||||
### SMTP Setup
|
||||
|
||||
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider setup.
|
||||
|
||||
### Cloudflare R2
|
||||
|
||||
For hosted deployments, Cloudflare R2 handles object storage. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide.
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Coolify
|
||||
description: Deploy ClassroomIO on Coolify using the pull-based Docker Compose stack.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
[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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Coolify instance with a connected server.
|
||||
- A domain pointed at that server (Coolify's built-in proxy terminates HTTPS for you).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **New Resource → Docker Compose.** Point Coolify at this repo and the compose path `docker-compose.images.yaml`, or paste the file's contents.
|
||||
|
||||
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:
|
||||
|
||||
```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=<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">
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Upgrades & rollback
|
||||
|
||||
Bump `CIO_VERSION` to a newer release and redeploy; roll back by setting it to the previous version and redeploying. Because you pin a version, a new release never restarts you onto broken images unattended.
|
||||
|
||||
<Callout type="warn" title="Back up before upgrading">
|
||||
Schema migrations run automatically on API startup. Back up your Postgres (and object storage)
|
||||
before a major upgrade.
|
||||
</Callout>
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
title: Docker
|
||||
description: Self-host ClassroomIO with Docker — pull pre-built images (recommended) or build from source.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
Docker is one way to self-host ClassroomIO. There are two paths:
|
||||
|
||||
- **Run from pre-built images (recommended)** — pull the published `classroomio/{api,dashboard,jobs}` images. You only need one compose file and a `.env`; no repository clone and no heavy local build.
|
||||
- **Build from source** — clone the repo and build the images yourself (needs ~8 GB RAM for the dashboard build).
|
||||
|
||||

|
||||
|
||||
## Run from Pre-built Images (recommended)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker installed and running.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Download the pull-based compose file and an env template:
|
||||
|
||||
```zsh title="Terminal"
|
||||
curl -O https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml
|
||||
curl -o .env https://raw.githubusercontent.com/classroomio/classroomio/main/.env.example
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
POSTGRES_PASSWORD=<strong password>
|
||||
# Email (required for signup/reset/invites)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
```
|
||||
|
||||
<Callout type="error" title="Set strong secrets — the stack won't start without them">
|
||||
This path does **not** run `run-docker-full-stack.sh`, so secrets are not auto-generated. You must
|
||||
set `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` yourself — e.g. `openssl rand -hex 32`. The
|
||||
compose file fails fast with a clear error if either is empty, so it never boots with a
|
||||
predictable session-signing secret.
|
||||
</Callout>
|
||||
|
||||
3. Pull and start the stack (MinIO is included via the `minio` profile):
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d
|
||||
```
|
||||
|
||||
4. Verify:
|
||||
|
||||
```zsh title="Terminal"
|
||||
curl -sS http://localhost:3081/ # API — returns JSON
|
||||
curl -I http://localhost:3082/ # Dashboard — returns 200
|
||||
```
|
||||
|
||||
- Dashboard: http://localhost:3082
|
||||
- API: http://localhost:3081
|
||||
- MinIO Console: http://localhost:9001 (log in with `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` from your `.env`)
|
||||
|
||||
## Build from Source
|
||||
|
||||
Use this if you want to build the images locally (e.g. to run code changes).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- `git` installed
|
||||
- ~8 GB RAM available for the dashboard build
|
||||
|
||||
### Steps
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```zsh title="Terminal"
|
||||
git clone https://github.com/classroomio/classroomio.git
|
||||
cd classroomio
|
||||
```
|
||||
|
||||
2. Copy the env template and edit it:
|
||||
|
||||
```zsh title="Terminal"
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and set `DASHBOARD_ORIGIN` for your domain. For local development the defaults work as-is — the startup script generates `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` when they are missing, and randomizes the MinIO credentials.
|
||||
|
||||
3. Build and start the full stack:
|
||||
|
||||
```zsh title="Terminal"
|
||||
./run-docker-full-stack.sh --build
|
||||
```
|
||||
|
||||
The script builds and starts Postgres, Redis, MinIO (object storage), the API, the dashboard, and the background **jobs** worker.
|
||||
|
||||
<Callout title="Default is pull, not build">
|
||||
Run without `--build` and the script **pulls** the published `classroomio/*` images at the
|
||||
`CIO_VERSION` in your `.env` instead of building locally — that's the faster default path.
|
||||
</Callout>
|
||||
|
||||
## Upgrading to a New Version
|
||||
|
||||
ClassroomIO publishes versioned images. `CIO_VERSION` controls which tag you run:
|
||||
|
||||
- Pin an exact release (e.g. `1.4.2`) in production so a new release never restarts you onto changed images unattended.
|
||||
- `latest` = newest stable release. `edge` = bleeding edge (last `main` build).
|
||||
|
||||
**Pre-built images path** — bump `CIO_VERSION` in `.env`, then re-pull and recreate:
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d
|
||||
```
|
||||
|
||||
**Rollback** — set `CIO_VERSION` back to the previous release and run the same two commands.
|
||||
|
||||
**Build-from-source path** — `git pull`, then re-run `./run-docker-full-stack.sh --build` to rebuild.
|
||||
|
||||
<Callout type="warn" title="Back up before upgrading">
|
||||
Database schema migrations run automatically when the API starts. Back up your Postgres volume
|
||||
(and object storage) before a major upgrade, and avoid `docker compose down -v` / `docker volume
|
||||
prune`, which delete your data volumes.
|
||||
</Callout>
|
||||
|
||||
## One `.env`, One Command
|
||||
|
||||
Docker self-hosting uses a single root `.env` file. Every `docker compose` command reads it via `--env-file .env` — you never need separate env files per service. See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the full template.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `POSTGRES_DB` | Database name | `classroomio` |
|
||||
| `POSTGRES_USER` | Database user | `postgres` |
|
||||
| `POSTGRES_PASSWORD` | Database password | `postgres` |
|
||||
| `BETTER_AUTH_SECRET` | Auth session signing secret (required — stack won't start if empty) | — |
|
||||
| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key (must be identical in api + dashboard) | Auto-generated by the script |
|
||||
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
|
||||
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
|
||||
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
|
||||
|
||||
### CSP Domains (Runtime)
|
||||
|
||||
CSP domain variables are **runtime** — set them in your `.env`. No image rebuild is needed when changing them.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
|
||||
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
|
||||
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
|
||||
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
|
||||
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
|
||||
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
|
||||
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
|
||||
|
||||
<Callout type="warn" title="CSP Domains">
|
||||
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, video, and font domains. The API does not need to be added for normal dashboard traffic because requests are proxied same-origin. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
|
||||
</Callout>
|
||||
|
||||
### Optional — Email
|
||||
|
||||
Configure SMTP. Without email config, the app works but cannot send invites, password resets, or notifications.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SENDER` | SMTP server |
|
||||
|
||||
### Optional — Object Storage
|
||||
|
||||
The bundled **MinIO** is included by default and the startup script auto-configures it — you only need to change these for custom credentials or an external S3-compatible service.
|
||||
|
||||
| Variable | Default |
|
||||
|----------|---------|
|
||||
| `MINIO_ROOT_USER` | randomized by the script (`minioadmin` if you skip it) |
|
||||
| `MINIO_ROOT_PASSWORD` | randomized by the script (`minioadmin` if you skip it) |
|
||||
| `OBJECT_STORAGE_ENDPOINT` | `http://minio:9000` |
|
||||
| `OBJECT_STORAGE_PUBLIC_ENDPOINT` | `http://localhost:9000` |
|
||||
| `OBJECT_STORAGE_ACCESS_KEY_ID` | matches `MINIO_ROOT_USER` |
|
||||
| `OBJECT_STORAGE_SECRET_ACCESS_KEY` | matches `MINIO_ROOT_PASSWORD` |
|
||||
| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` |
|
||||
| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` |
|
||||
|
||||
<Callout title="Using AWS S3 or Cloudflare R2 instead of MinIO">
|
||||
The `OBJECT_STORAGE_*` variables speak the S3 API, so they work with any S3-compatible provider.
|
||||
To use Cloudflare R2 or AWS S3, point `OBJECT_STORAGE_ENDPOINT` at the provider's endpoint and set
|
||||
the matching access key / secret, then start without the bundled MinIO via
|
||||
`./run-docker-full-stack.sh --no-minio`. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
|
||||
</Callout>
|
||||
|
||||
### Optional — Integrations
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth |
|
||||
| `PRIVATE_APP_HOST`, `PRIVATE_APP_SUBDOMAINS` | Multi-subdomain routing |
|
||||
| `UNSPLASH_API_KEY` | Unsplash image search |
|
||||
| `OPENAI_API_KEY` | Transcription + AI course generation (used by the jobs worker) |
|
||||
| `LICENSE_KEY` | Enterprise features (SSO, token-auth, no-tracking) |
|
||||
| `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```zsh title="Terminal"
|
||||
# Start the full stack (pulls pre-built images by default)
|
||||
./run-docker-full-stack.sh
|
||||
|
||||
# Build from source instead of pulling
|
||||
./run-docker-full-stack.sh --build
|
||||
|
||||
# Start without MinIO (requires an external S3-compatible store)
|
||||
./run-docker-full-stack.sh --no-minio
|
||||
```
|
||||
|
||||
See [docker/docs/SELF_HOST.md](https://github.com/classroomio/classroomio/blob/main/docker/docs/SELF_HOST.md) for the full Docker reference including troubleshooting.
|
||||
|
||||
## Troubleshooting: "No space left on device"
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker system df # check usage
|
||||
docker builder prune -f # free build cache
|
||||
docker image prune -a -f # free unused images
|
||||
docker container prune -f # free stopped containers
|
||||
```
|
||||
|
||||
<Callout type="warn" title="Volume prune deletes data">
|
||||
Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes.
|
||||
</Callout>
|
||||
|
||||
Then restart: `./run-docker-full-stack.sh`
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Self Hosting",
|
||||
"pages": ["docker", "railway", "coolify"]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Railway
|
||||
description: Deploy ClassroomIO on Railway with the one-click template.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
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.
|
||||
|
||||
## cio-api
|
||||
|
||||
```zsh title="env"
|
||||
DATABASE_URL="${{db.DATABASE_URL}}"
|
||||
REDIS_URL="${{Redis.REDIS_URL}}"
|
||||
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
|
||||
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # must be identical in cio-api and cio-dashboard
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
|
||||
# Dashboard URL for invite/email links. Reference the dashboard service's Railway domain
|
||||
# so it stays correct automatically. (For a custom domain, hardcode it: https://app.yourdomain.com)
|
||||
DASHBOARD_ORIGIN="https://${{cio-dashboard.RAILWAY_PUBLIC_DOMAIN}}"
|
||||
|
||||
# Optional direct public API URL. Normal dashboard auth/API calls use the
|
||||
# dashboard proxy and do not require API/dashboard cookie-domain matching.
|
||||
PUBLIC_SERVER_URL="https://api.yourdomain.com"
|
||||
TRUSTED_ORIGINS=
|
||||
|
||||
# Email (configure SMTP)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
|
||||
# Object storage → the self-hosted cio-minio service (see below)
|
||||
OBJECT_STORAGE_ENDPOINT=http://${{cio-minio.RAILWAY_PRIVATE_DOMAIN}}:9000
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT=https://storage.yourdomain.com
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID=${{cio-minio.MINIO_ROOT_USER}}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY=${{cio-minio.MINIO_ROOT_PASSWORD}}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||
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
|
||||
|
||||
```zsh title="env"
|
||||
# RAILWAY_PUBLIC_DOMAIN here = this dashboard service's own public domain.
|
||||
# For a custom domain, hardcode app.yourdomain.com in ORIGIN / PRIVATE_APP_HOST instead.
|
||||
ORIGIN="https://${{RAILWAY_PUBLIC_DOMAIN}}"
|
||||
PRIVATE_APP_HOST="${{RAILWAY_PUBLIC_DOMAIN}}"
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
PRIVATE_SERVER_URL="http://${{cio-api.RAILWAY_PRIVATE_DOMAIN}}:${{cio-api.PORT}}"
|
||||
PRIVATE_SERVER_KEY="${{cio-api.PRIVATE_SERVER_KEY}}" # same value as cio-api
|
||||
ALLOWED_EXTERNAL_DOMAINS="https://fonts.googleapis.com"
|
||||
```
|
||||
|
||||
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
|
||||
|
||||
## 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:
|
||||
|
||||
```zsh title="env"
|
||||
DATABASE_URL="${{db.DATABASE_URL}}"
|
||||
REDIS_URL="${{Redis.REDIS_URL}}"
|
||||
|
||||
# Object storage — same cio-minio service as the API
|
||||
OBJECT_STORAGE_ENDPOINT=http://${{cio-minio.RAILWAY_PRIVATE_DOMAIN}}:9000
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT=https://storage.yourdomain.com
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID=${{cio-minio.MINIO_ROOT_USER}}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY=${{cio-minio.MINIO_ROOT_PASSWORD}}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=https://storage.yourdomain.com/media
|
||||
|
||||
# Email (worker sends invites, notifications, etc.)
|
||||
SMTP_HOST= SMTP_PORT= SMTP_USER= SMTP_PASSWORD= SMTP_SENDER=
|
||||
|
||||
# Transcription + OpenAI-backed AI course generation
|
||||
OPENAI_API_KEY=
|
||||
```
|
||||
|
||||
<Callout title="PRIVATE_SERVER_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.
|
||||
</Callout>
|
||||
|
||||
## 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.
|
||||
|
||||
```zsh title="env"
|
||||
MINIO_ROOT_USER=<strong user>
|
||||
MINIO_ROOT_PASSWORD=<strong password>
|
||||
# Optional (silences console redirect warnings behind the proxy):
|
||||
MINIO_SERVER_URL=https://storage.yourdomain.com
|
||||
```
|
||||
|
||||
The api and jobs services reach MinIO over **two** endpoints: the private `OBJECT_STORAGE_ENDPOINT`
|
||||
(server uploads/reads) and the public `OBJECT_STORAGE_PUBLIC_ENDPOINT` + `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`
|
||||
(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)
|
||||
```
|
||||
|
||||
<Callout type="warn" title="MinIO is your durability boundary">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
## SMTP Setup
|
||||
|
||||
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider setup.
|
||||
|
||||
## 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.
|
||||
@@ -1,35 +0,0 @@
|
||||
FROM node:20-bookworm-slim AS base
|
||||
|
||||
# ffmpeg is needed for media probe / thumbnail / audio-extract / transcode tasks.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace manifest and lockfile first for cache reuse.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/jobs/package.json apps/jobs/
|
||||
COPY packages/jobs/package.json packages/jobs/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/utils/package.json packages/utils/
|
||||
COPY packages/tsconfig/package.json packages/tsconfig/
|
||||
COPY packages/email/package.json packages/email/
|
||||
COPY packages/question-types/package.json packages/question-types/
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @cio/jobs-worker...
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm --filter @cio/utils build \
|
||||
&& pnpm --filter @cio/db build \
|
||||
&& pnpm --filter @cio/jobs build \
|
||||
&& pnpm --filter @cio/jobs-worker build
|
||||
|
||||
# Run all worker entrypoints by default. Override CMD to scale media / transcribe
|
||||
# / maintenance independently.
|
||||
CMD ["node", "apps/jobs/dist/index.js"]
|
||||
@@ -17,7 +17,7 @@ Today we are happy to share that you can now easily self-host classroomio and us
|
||||
|
||||
If you are interested in just trying out the product, it is advisable to use the [cloud version](https://app.classroomio.com). You can use almost all the features for free on the cloud version unless you need more students. Right now the main reason you'd have to upgrade is because your students are growing. If you don't want to upgrade and/or face this, then you'd be fine self hosting the whole application.
|
||||
|
||||
For self-hosting, we have written up a [guide that uses Railway](https://classroomio.com/docs/quickstart/self-hosting) to help deploy our stack and you get to manage the servers on Railway as you wish. We hope this is a good step in helping you educate your audience at ease.
|
||||
For self-hosting, we have written up a [guide that uses Railway](https://classroomio.com/docs/self-hosted/railway) to help deploy our stack and you get to manage the servers on Railway as you wish. We hope this is a good step in helping you educate your audience at ease.
|
||||
|
||||
Below is a demo showing you step by step how to self host the project
|
||||
|
||||
@@ -25,6 +25,6 @@ Below is a demo showing you step by step how to self host the project
|
||||
|
||||
### Next steps
|
||||
|
||||
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for both our api and dashboard projects however contributions from the open source community would help us in properly testing and improving them.
|
||||
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for our api, dashboard, and jobs worker projects however contributions from the open source community would help us in properly testing and improving them.
|
||||
|
||||
If you prefer deployment with Docker, do checkout our [docker-compose.yaml](https://github.com/classroomio/classroomio/blob/main/docker-compose.yaml) file to get started and we can kick off a conversation on Github.
|
||||
|
||||
Reference in New Issue
Block a user