2026-03-09 11:18:22 +01:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
2026-06-23 10:17:09 +01:00
|
|
|
BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml"
|
|
|
|
|
IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml"
|
2026-03-09 11:18:22 +01:00
|
|
|
PROJECT_NAME="classroomio"
|
|
|
|
|
ENV_FILE="${ROOT_DIR}/.env"
|
2026-06-23 10:17:09 +01:00
|
|
|
# Default to PULLING pre-built images (fast). Pass --build to build from source instead.
|
|
|
|
|
USE_IMAGES=true
|
|
|
|
|
BUILD_IMAGES=false
|
|
|
|
|
COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
|
2026-03-09 11:18:22 +01:00
|
|
|
COMPOSE_PROFILES="--profile minio"
|
|
|
|
|
|
|
|
|
|
print_usage() {
|
|
|
|
|
cat <<'USAGE'
|
2026-06-23 10:17:09 +01:00
|
|
|
Usage: ./run-docker-full-stack.sh [--build] [--no-build] [--no-minio]
|
|
|
|
|
|
|
|
|
|
By default this PULLS the pre-built classroomio/* images from Docker Hub at the tag in
|
|
|
|
|
CIO_VERSION (.env) — no local build, no full repo needed.
|
2026-03-09 11:18:22 +01:00
|
|
|
|
|
|
|
|
Options:
|
2026-06-23 10:17:09 +01:00
|
|
|
--build Build the images from source (docker-compose.yaml) instead of pulling.
|
|
|
|
|
--no-build With --build, start containers without rebuilding.
|
2026-03-09 11:18:22 +01:00
|
|
|
--no-minio Exclude MinIO (object storage). By default MinIO is included.
|
2026-06-23 10:17:09 +01:00
|
|
|
--images Deprecated alias — pulling images is now the default.
|
2026-03-09 11:18:22 +01:00
|
|
|
-h, --help Show this help message.
|
|
|
|
|
USAGE
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
|
|
|
case "$1" in
|
2026-06-23 10:17:09 +01:00
|
|
|
--build)
|
|
|
|
|
USE_IMAGES=false
|
|
|
|
|
BUILD_IMAGES=true
|
|
|
|
|
COMPOSE_FILE="${BUILD_COMPOSE_FILE}"
|
|
|
|
|
shift
|
|
|
|
|
;;
|
2026-03-09 11:18:22 +01:00
|
|
|
--no-build)
|
|
|
|
|
BUILD_IMAGES=false
|
|
|
|
|
shift
|
|
|
|
|
;;
|
|
|
|
|
--no-minio)
|
|
|
|
|
COMPOSE_PROFILES=""
|
|
|
|
|
shift
|
|
|
|
|
;;
|
2026-06-23 10:17:09 +01:00
|
|
|
--images)
|
|
|
|
|
# Pulling is the default now; kept for backward compatibility.
|
|
|
|
|
USE_IMAGES=true
|
|
|
|
|
BUILD_IMAGES=false
|
|
|
|
|
COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
|
|
|
|
|
shift
|
|
|
|
|
;;
|
2026-03-09 11:18:22 +01:00
|
|
|
-h|--help)
|
|
|
|
|
print_usage
|
|
|
|
|
exit 0
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
echo "Unknown option: $1"
|
|
|
|
|
print_usage
|
|
|
|
|
exit 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
get_env_value() {
|
|
|
|
|
local key="$1"
|
|
|
|
|
local line
|
|
|
|
|
line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)"
|
|
|
|
|
printf '%s' "${line#*=}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upsert_env_value() {
|
|
|
|
|
local key="$1"
|
|
|
|
|
local value="$2"
|
|
|
|
|
local tmp_file
|
|
|
|
|
|
|
|
|
|
tmp_file="$(mktemp)"
|
|
|
|
|
awk -v key="${key}" -v value="${value}" '
|
|
|
|
|
BEGIN { updated = 0 }
|
|
|
|
|
$0 ~ ("^" key "=") {
|
|
|
|
|
if (updated == 0) {
|
|
|
|
|
print key "=" value;
|
|
|
|
|
updated = 1;
|
|
|
|
|
}
|
|
|
|
|
next;
|
|
|
|
|
}
|
|
|
|
|
{ print }
|
|
|
|
|
END {
|
|
|
|
|
if (updated == 0) {
|
|
|
|
|
print key "=" value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
' "${ENV_FILE}" > "${tmp_file}"
|
|
|
|
|
|
|
|
|
|
mv "${tmp_file}" "${ENV_FILE}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
is_insecure_token_value() {
|
|
|
|
|
local value="$1"
|
|
|
|
|
local normalized
|
|
|
|
|
|
|
|
|
|
normalized="$(printf '%s' "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
|
|
|
|
|
|
|
|
if [[ "${normalized}" =~ ^\"(.*)\"$ ]]; then
|
|
|
|
|
normalized="${BASH_REMATCH[1]}"
|
|
|
|
|
elif [[ "${normalized}" =~ ^\'(.*)\'$ ]]; then
|
|
|
|
|
normalized="${BASH_REMATCH[1]}"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
case "${normalized}" in
|
2026-06-23 10:17:09 +01:00
|
|
|
"" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me")
|
2026-03-09 11:18:22 +01:00
|
|
|
return 0
|
|
|
|
|
;;
|
|
|
|
|
replace-with-* | *local-dev* | *change-this* | *your-*)
|
|
|
|
|
return 0
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
generate_secure_token() {
|
|
|
|
|
if command -v openssl >/dev/null 2>&1; then
|
|
|
|
|
openssl rand -hex 32
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if command -v node >/dev/null 2>&1; then
|
|
|
|
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
|
|
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
date +%s%N | shasum | awk '{ print $1 }'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensure_secure_auth_tokens() {
|
|
|
|
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
|
|
|
|
touch "${ENV_FILE}"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
local private_server_key
|
|
|
|
|
private_server_key="$(get_env_value PRIVATE_SERVER_KEY)"
|
|
|
|
|
|
2026-06-23 10:17:09 +01:00
|
|
|
# PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be
|
|
|
|
|
# identical in both services. Generate a strong value when it's empty or a known placeholder.
|
|
|
|
|
if is_insecure_token_value "${private_server_key}"; then
|
|
|
|
|
upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)"
|
|
|
|
|
echo "Generated secure PRIVATE_SERVER_KEY in .env"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensure_secure_betterauth_secret() {
|
|
|
|
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
|
|
|
|
touch "${ENV_FILE}"
|
2026-03-09 11:18:22 +01:00
|
|
|
fi
|
|
|
|
|
|
2026-06-23 10:17:09 +01:00
|
|
|
local secret
|
|
|
|
|
secret="$(get_env_value BETTER_AUTH_SECRET)"
|
2026-03-09 11:18:22 +01:00
|
|
|
|
2026-06-23 10:17:09 +01:00
|
|
|
# Only (re)generate when the value is empty or a known placeholder; never clobber
|
|
|
|
|
# a strong secret the user has set themselves.
|
|
|
|
|
if is_insecure_token_value "${secret}"; then
|
|
|
|
|
upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)"
|
|
|
|
|
echo "Generated secure BETTER_AUTH_SECRET in .env"
|
2026-03-09 11:18:22 +01:00
|
|
|
fi
|
2026-06-23 10:17:09 +01:00
|
|
|
}
|
2026-03-09 11:18:22 +01:00
|
|
|
|
2026-06-23 10:17:09 +01:00
|
|
|
is_local_origin() {
|
|
|
|
|
local value="$1"
|
|
|
|
|
case "${value}" in
|
|
|
|
|
"" | *localhost* | *127.0.0.1* | *0.0.0.0*)
|
|
|
|
|
return 0
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not
|
|
|
|
|
# localhost. MinIO media is served on :9000 — operators must reverse-proxy /media to it.
|
|
|
|
|
# We only override values that are still empty/localhost so explicit S3/R2 config is kept.
|
|
|
|
|
derive_public_storage_urls() {
|
|
|
|
|
local dashboard_origin public_endpoint media_base
|
|
|
|
|
dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)"
|
|
|
|
|
public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)"
|
|
|
|
|
media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)"
|
|
|
|
|
|
|
|
|
|
if ! is_local_origin "${dashboard_origin}"; then
|
|
|
|
|
local origin="${dashboard_origin%/}"
|
|
|
|
|
# Public media is served from the public-download `media` bucket, so deriving its base
|
|
|
|
|
# URL from the domain is what fixes broken media links in served pages. We deliberately
|
|
|
|
|
# do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the
|
|
|
|
|
# dashboard origin would break presigned URLs for the non-public videos/documents buckets.
|
|
|
|
|
if is_local_origin "${media_base}"; then
|
|
|
|
|
upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${origin}/media"
|
|
|
|
|
fi
|
|
|
|
|
echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${origin}/media)."
|
|
|
|
|
echo " -> Ensure your reverse proxy routes ${origin}/media to MinIO (port 9000)."
|
|
|
|
|
echo " -> For presigned access to the videos/documents buckets behind a domain, expose"
|
|
|
|
|
echo " MinIO's S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly."
|
|
|
|
|
else
|
|
|
|
|
# Local demo: ensure localhost defaults are present.
|
|
|
|
|
if [[ -z "${public_endpoint}" ]]; then
|
|
|
|
|
upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000"
|
|
|
|
|
fi
|
|
|
|
|
if [[ -z "${media_base}" ]]; then
|
|
|
|
|
upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media"
|
|
|
|
|
fi
|
2026-03-09 11:18:22 +01:00
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensure_minio_env() {
|
|
|
|
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
|
|
|
|
touch "${ENV_FILE}"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-06-23 10:17:09 +01:00
|
|
|
local minio_password
|
|
|
|
|
minio_password="$(get_env_value MINIO_ROOT_PASSWORD)"
|
2026-03-09 11:18:22 +01:00
|
|
|
|
2026-06-23 10:17:09 +01:00
|
|
|
# First provision: randomize credentials whenever the MinIO password is still empty or the
|
|
|
|
|
# well-known `minioadmin` placeholder shipped in .env.example. (Previously this gated on
|
|
|
|
|
# OBJECT_STORAGE_ENDPOINT being empty, but .env.example ships it non-empty — so on a normal
|
|
|
|
|
# copy-the-example first run the randomization was always skipped and minioadmin/minioadmin
|
|
|
|
|
# survived.) MinIO uses its root credentials as the S3 access key / secret.
|
|
|
|
|
if [[ -z "${minio_password}" || "${minio_password}" =~ ^[[:space:]]*$ || "${minio_password}" == "minioadmin" ]]; then
|
|
|
|
|
local minio_user
|
|
|
|
|
minio_user="cio-$(generate_secure_token | cut -c1-16)"
|
|
|
|
|
minio_password="$(generate_secure_token)"
|
|
|
|
|
|
|
|
|
|
upsert_env_value MINIO_ROOT_USER "${minio_user}"
|
|
|
|
|
upsert_env_value MINIO_ROOT_PASSWORD "${minio_password}"
|
2026-03-09 11:18:22 +01:00
|
|
|
upsert_env_value OBJECT_STORAGE_ENDPOINT "http://minio:9000"
|
2026-06-23 10:17:09 +01:00
|
|
|
upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "${minio_user}"
|
|
|
|
|
upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "${minio_password}"
|
2026-03-09 11:18:22 +01:00
|
|
|
upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true"
|
2026-06-23 10:17:09 +01:00
|
|
|
echo "Provisioned MinIO with randomized credentials in .env"
|
2026-03-09 11:18:22 +01:00
|
|
|
fi
|
2026-06-23 10:17:09 +01:00
|
|
|
|
|
|
|
|
derive_public_storage_urls
|
2026-03-09 11:18:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
|
|
|
echo "Error: docker is not installed or not on PATH."
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
ensure_secure_auth_tokens
|
2026-06-23 10:17:09 +01:00
|
|
|
ensure_secure_betterauth_secret
|
2026-03-09 11:18:22 +01:00
|
|
|
|
|
|
|
|
if [[ "${COMPOSE_PROFILES}" == *"minio"* ]]; then
|
|
|
|
|
ensure_minio_env
|
2026-06-23 10:17:09 +01:00
|
|
|
else
|
|
|
|
|
# MinIO excluded: the user must supply external object storage, or uploads/media
|
|
|
|
|
# silently break. Fail fast instead. The endpoint must be set AND not still point at the
|
|
|
|
|
# bundled MinIO host (which won't be running with --no-minio).
|
|
|
|
|
external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)"
|
|
|
|
|
if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"minio:9000"* ]]; then
|
|
|
|
|
echo "Error: --no-minio was passed but no external object storage is configured."
|
|
|
|
|
echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled MinIO (minio:9000)."
|
|
|
|
|
echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env,"
|
|
|
|
|
echo "or drop --no-minio to use the bundled MinIO."
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
derive_public_storage_urls
|
2026-03-09 11:18:22 +01:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo "Starting ClassroomIO Docker full stack..."
|
|
|
|
|
if [[ "${COMPOSE_PROFILES}" != "" ]]; then
|
|
|
|
|
echo "Including MinIO (object storage, default)..."
|
|
|
|
|
fi
|
2026-06-23 10:17:09 +01:00
|
|
|
if [[ "${USE_IMAGES}" == "true" ]]; then
|
|
|
|
|
echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..."
|
|
|
|
|
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} pull
|
|
|
|
|
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d
|
|
|
|
|
elif [[ "${BUILD_IMAGES}" == "true" ]]; then
|
2026-03-09 11:18:22 +01:00
|
|
|
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up --build -d
|
|
|
|
|
else
|
|
|
|
|
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo
|
|
|
|
|
echo "Current service status:"
|
|
|
|
|
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ps
|
|
|
|
|
|
2026-06-23 10:17:09 +01:00
|
|
|
# `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh
|
|
|
|
|
# deployment the API runs DB migrations before it listens, so the endpoints aren't reachable
|
|
|
|
|
# immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready
|
|
|
|
|
# check abort the script (set -euo pipefail is active), so first runs end cleanly.
|
|
|
|
|
check_endpoint() {
|
|
|
|
|
local name="$1" url="$2" curl_flags="$3" attempts="$4"
|
|
|
|
|
local i=0
|
|
|
|
|
while ((i < attempts)); do
|
|
|
|
|
if curl ${curl_flags} --max-time 5 "${url}" >/dev/null 2>&1; then
|
|
|
|
|
echo "${name} is reachable on ${url}"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
i=$((i + 1))
|
|
|
|
|
sleep 5
|
|
|
|
|
done
|
|
|
|
|
echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)."
|
|
|
|
|
echo " It may still be starting — the first run migrates the database. Follow logs with:"
|
|
|
|
|
echo " docker compose --env-file \"${ENV_FILE}\" -p \"${PROJECT_NAME}\" -f \"${COMPOSE_FILE}\" logs -f"
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
if command -v curl >/dev/null 2>&1; then
|
|
|
|
|
echo
|
2026-06-23 10:17:09 +01:00
|
|
|
echo "Waiting for services to come up (first run can take a minute while the API migrates)..."
|
|
|
|
|
check_endpoint "API" "http://localhost:3081/" "-fsS" 24
|
|
|
|
|
check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12
|
2026-03-09 11:18:22 +01:00
|
|
|
else
|
|
|
|
|
echo
|
|
|
|
|
echo "curl not found, skipped endpoint checks."
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo
|
|
|
|
|
echo "Done. Full stack is running."
|