#!/bin/sh # # Bootstrap a self-hosted Supabase project on Linux (Debian/Ubuntu or RHEL/CentOS/Fedora). # # What it does: # 1. Installs prerequisites: git, openssl, jq, ca-certificates # 2. Installs Docker Engine + Compose plugin (if missing) # 3. Optionally installs the AWS CLI v2 (--with-aws) # 4. Sparse-clones the repo to extract the contents of ./docker # 5. Creates a project directory in CWD and copies docker/* into it # 6. Records the base version the deployment was set up from (.supabase-version) # 7. Prompts for the main URLs and writes them to .env # 8. Generates secrets and asymmetric API keys via utils/*.sh # # Usage: # sh setup.sh # interactive # sh setup.sh -y # accept defaults, no prompts # sh setup.sh --project-dir my-supabase # name the project directory # sh setup.sh --skip-deps # skip system-package installation # sh setup.sh --with-aws # also install the AWS CLI v2 # sh setup.sh --ref self-hosted/v0.7.0 # clone docker/ from a specific git ref # sh setup.sh --head # clone docker/ from HEAD (skip tags) # # curl -fsSL | sh # bootstrap from scratch in CWD # # By default the docker/ sources are cloned from the latest self-hosted release # tag (self-hosted/v*), falling back to the default branch (HEAD) if none exist. # set -e PROJECT_DIR="supabase-project" SKIP_DEPS=0 WITH_AWS=0 ASSUME_YES=0 SOURCE_REF="" FORCE_HEAD=0 print_help() { cat < Name of the project directory (default: supabase-project) --skip-deps Skip installation of system packages --with-aws Install the AWS CLI v2 --ref Clone docker/ from this git ref instead of the latest self-hosted tag (no HEAD fallback) --head Clone docker/ from the default branch (HEAD), skipping self-hosted tag detection -y, --yes Non-interactive: accept defaults, no prompts -h, --help Show this help and exit EOF } while [ $# -gt 0 ]; do case "$1" in -p|--project-dir) PROJECT_DIR="$2"; shift 2 ;; --skip-deps) SKIP_DEPS=1; shift ;; --with-aws) WITH_AWS=1; shift ;; --ref) SOURCE_REF="$2"; shift 2 ;; --head) FORCE_HEAD=1; shift ;; -y|--yes) ASSUME_YES=1; shift ;; -h|--help) print_help; exit 0 ;; *) echo "Unknown option: $1" >&2; print_help; exit 1 ;; esac done # Interactive vs not: -y forces non-interactive; otherwise we're non-interactive # when there's no controlling terminal to prompt on (e.g. curl | sh in CI). if [ "$ASSUME_YES" = "1" ] || ! ( : < /dev/tty ) 2>/dev/null; then NON_INTERACTIVE=1 else NON_INTERACTIVE=0 fi if [ "$(id -u)" = "0" ]; then SUDO="" else SUDO="sudo" fi log() { printf "===> %s\n" "$*"; } warn() { printf "WARNING: %s\n" "$*" >&2; } die() { printf "ERROR: %s\n" "$*" >&2; exit 1; } # Prompt with a default; echoes the chosen value on stdout. # Reads from /dev/tty so prompts work even when stdin is a pipe (curl | sh). # Falls back to the default with -y or when no controlling terminal exists. ask() { # ask -> echoes chosen value if [ "$NON_INTERACTIVE" = "1" ]; then printf '%s' "$2" return fi printf "%s [%s]: " "$1" "$2" > /dev/tty read -r reply < /dev/tty [ -z "$reply" ] && reply="$2" printf '%s' "$reply" } # True if the value looks like an http(s) URL (scheme check only, not validation). valid_url() { case "$1" in http://?*|https://?*) return 0 ;; *) return 1 ;; esac } # Like ask, but requires an http(s):// value. Re-prompts interactively; with no # usable terminal (-y or curl | sh) a bad value is fatal rather than silently kept. ask_url() { # ask_url -> echoes a validated URL while :; do _url=$(ask "$1" "$2") valid_url "$_url" && { printf '%s' "$_url"; return 0; } if [ "$NON_INTERACTIVE" = "1" ]; then die "$1 must start with http:// or https:// (got: '$_url')" fi printf " '%s' is not a URL - it must start with http:// or https://.\n" "$_url" > /dev/tty done } OS_FAMILY="" OS_ID="" OS_CODENAME="" detect_os() { [ -f /etc/os-release ] || die "Cannot detect OS: /etc/os-release missing. Linux only." # shellcheck disable=SC1091 . /etc/os-release OS_ID="$ID" OS_CODENAME="${VERSION_CODENAME:-}" case "$ID" in ubuntu|debian) OS_FAMILY="debian" ;; centos|rhel|fedora|rocky|almalinux|ol|amzn) OS_FAMILY="rhel" ;; *) case "${ID_LIKE:-}" in *debian*|*ubuntu*) OS_FAMILY="debian" ;; *rhel*|*fedora*|*centos*) OS_FAMILY="rhel" ;; *) die "Unsupported distribution: $ID" ;; esac ;; esac log "Detected OS: $ID ($OS_FAMILY)" } pkg_update() { if [ "$OS_FAMILY" = "debian" ]; then $SUDO apt-get update -qq -y else $SUDO dnf makecache -q -y || true fi } pkg_install() { if [ "$OS_FAMILY" = "debian" ]; then export DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -qq -y "$@" else $SUDO dnf install -q -y "$@" fi } install_base_packages() { log "Installing base packages: git, openssl, jq, ca-certificates" pkg_update if [ "$OS_FAMILY" = "debian" ]; then pkg_install git openssl jq ca-certificates \ apt-transport-https gnupg lsb-release else pkg_install git openssl jq ca-certificates dnf-plugins-core fi } docker_present() { command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 } install_docker() { if docker_present; then log "Docker already installed: $(docker --version)" return 0 fi log "Installing Docker Engine and Compose plugin" if [ "$OS_FAMILY" = "debian" ]; then $SUDO install -m 0755 -d /etc/apt/keyrings curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" \ | $SUDO gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg $SUDO chmod a+r /etc/apt/keyrings/docker.gpg codename="${OS_CODENAME:-$(lsb_release -cs 2>/dev/null || echo stable)}" echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable" \ | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null $SUDO apt-get update -y pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin else # Amazon Linux if [ "$OS_ID" = "amzn" ]; then # Install Docker from the repo pkg_install docker # Install Docker Compose $SUDO mkdir -p /usr/local/lib/docker/cli-plugins && \ $SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \ -o /usr/local/lib/docker/cli-plugins/docker-compose && \ $SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose else repo_distro="centos" case "$OS_ID" in fedora) repo_distro="fedora" ;; rhel) repo_distro="rhel" ;; esac $SUDO dnf config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" 2>/dev/null \ || $SUDO dnf-3 config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin fi fi log "Enabling and starting docker service" $SUDO systemctl enable --now docker || warn "Could not enable docker via systemctl; start it manually." docker_present || die "Docker installation finished but 'docker compose' is still unavailable." } install_aws() { if command -v aws >/dev/null 2>&1; then log "AWS CLI already installed: $(aws --version 2>&1)" return 0 fi arch=$(uname -m) case "$arch" in x86_64|amd64) aws_arch="x86_64" ;; aarch64|arm64) aws_arch="aarch64" ;; *) die "Unsupported architecture for AWS CLI: $arch" ;; esac log "Installing AWS CLI v2 (${aws_arch})" command -v unzip >/dev/null 2>&1 || pkg_install unzip tmp=$(mktemp -d) ( cd "$tmp" curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${aws_arch}.zip" -o awscliv2.zip unzip -q -o awscliv2.zip $SUDO ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update ) rm -rf "$tmp" } SRC_DIR="" SRC_TMP="" RESOLVED_REF="" REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}" STAMP_FILE=".supabase-version" # Highest self-hosted/v* tag on the remote, or empty when the remote has none. # Returns non-zero (printing nothing) when the remote can't be reached. latest_release_tag() { _refs=$(git ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || return 1 printf '%s\n' "$_refs" \ | sed 's#^.*refs/tags/##' \ | grep -E '^self-hosted/v[0-9]' \ | sort -V | tail -n1 } # Clone only docker/ from (empty = default branch) into . sparse_clone() { # sparse_clone [ref] _dest="$1" _ref="$2" if [ -n "$_ref" ]; then git clone --filter=blob:none --no-checkout --depth=1 --quiet \ --branch "$_ref" "$REPO_URL" "$_dest" 2>/dev/null || return 1 else git clone --filter=blob:none --no-checkout --depth=1 --quiet \ "$REPO_URL" "$_dest" 2>/dev/null || return 1 fi ( cd "$_dest" && git sparse-checkout init --cone && git sparse-checkout set docker && git checkout --quiet ) 2>/dev/null || return 1 } # Echo the commit the clone resolved to (for stamping HEAD-based checkouts). resolved_sha() { git -C "$1" rev-parse HEAD 2>/dev/null || true } prepare_source() { SRC_TMP=$(mktemp -d) || die "Could not create a temporary directory" dest="$SRC_TMP/supabase" # Pick the ref to clone; empty means the default branch (HEAD), used only # when no release tag exists yet (or --head). A resolved tag that fails to # clone is an error, not a reason to silently install HEAD instead. if [ -n "$SOURCE_REF" ]; then ref="$SOURCE_REF" elif [ "$FORCE_HEAD" = "1" ]; then ref="" else if ref=$(latest_release_tag); then [ -n "$ref" ] || log "No self-hosted release tag found; using the default branch (HEAD)" else die "Could not reach $REPO_URL to look up release tags. Check your network and retry, or pass --head (default branch) or --ref ." fi fi log "Sparse-cloning supabase repo at ${ref:-HEAD}" sparse_clone "$dest" "$ref" || die "Could not clone '${ref:-HEAD}' from $REPO_URL" # Stamp the tag name for a release tag, else the exact commit SHA. case "$ref" in self-hosted/v*) RESOLVED_REF="$ref" ;; *) RESOLVED_REF=$(resolved_sha "$dest") ;; esac SRC_DIR="$dest/docker" } cleanup_src_tmp() { if [ -n "$SRC_TMP" ] && [ -d "$SRC_TMP" ]; then rm -rf "$SRC_TMP" fi } trap cleanup_src_tmp EXIT read_env() { grep "^$1=" .env 2>/dev/null | head -n1 | cut -d= -f2- } # Record the base version this deployment was set up from, so update.sh can # 3-way merge future upgrades against it: it fetches the snapshot at this ref # as the merge base, and derives the base version from the ref itself. Just the # ref - per-deployment state, not vendor content: gitignored. write_version_stamp() { [ -n "$1" ] || { warn "Could not resolve a base ref; skipping $STAMP_FILE"; return 0; } { echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh." echo "# Do not commit or edit by hand. Records the ref this deployment was based on." echo "ref=$1" } > "$STAMP_FILE" log "Recorded base version in $STAMP_FILE (ref=$1)" } # --- Main --- log "Setup starting in $(pwd)" log "This may take several minutes..." if [ "$SKIP_DEPS" = "1" ]; then log "Skipping system-package installation (--skip-deps)" else detect_os install_base_packages install_docker fi if [ "$WITH_AWS" = "1" ]; then install_aws fi # Idempotent re-run: if CWD is already a set-up project, skip bootstrap. # A clone has docker-compose.yml + utils/ but only .env.example; # a set-up project also has a real .env. if [ -f .env ] && [ -f docker-compose.yml ] && [ -d utils ]; then log "Already in a Supabase project directory; skipping bootstrap." exit 0 fi prepare_source target="$(pwd)/$PROJECT_DIR" if [ -e "$target" ]; then die "Target $target already exists. Pick a different name with --project-dir" fi log "Creating project at $target" mkdir -p "$target" cp -rf "$SRC_DIR/." "$target/" if [ -f "$target/.env.example" ] && [ ! -f "$target/.env" ]; then cp "$target/.env.example" "$target/.env" fi cd "$target" current_public_url=$(read_env SUPABASE_PUBLIC_URL) current_site_url=$(read_env SITE_URL) [ -z "$current_public_url" ] && current_public_url="http://localhost:8000" [ -z "$current_site_url" ] && current_site_url="http://localhost:3000" if [ "$NON_INTERACTIVE" = "1" ]; then log "Non-interactive: using default URLs (edit .env to change)" else echo "" echo "Configure the main URLs (press Enter to accept the default)." echo "" fi public_url=$(ask_url "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url") api_url=$(ask_url "API_EXTERNAL_URL (Auth callbacks)" "$public_url/auth/v1") site_url=$(ask_url "SITE_URL (default Auth redirect)" "$current_site_url") # Suggest PROXY_DOMAIN from the public_url host (unless it's localhost-ish) public_host=$(printf '%s' "$public_url" | sed -e 's|^https*://||' -e 's|/.*$||' -e 's|:.*$||') case "$public_host" in localhost|127.*|"") current_proxy_domain=$(read_env PROXY_DOMAIN) ;; *) current_proxy_domain="$public_host" ;; esac [ -z "$current_proxy_domain" ] && current_proxy_domain="your-domain.example.com" proxy_domain=$(ask "PROXY_DOMAIN (for nginx/caddy HTTPS proxy)" "$current_proxy_domain") # Derive CERTBOT_EMAIL = admin@. # Naive: doesn't handle ccTLDs like .co.uk; user can edit .env after. domain_root=$(printf '%s' "$proxy_domain" | awk -F. 'NF>=2 { print $(NF-1)"."$NF; next } { print }') certbot_email="admin@${domain_root}" log "Setting CERTBOT_EMAIL=${certbot_email}" sed -i.old \ -e "s|^SUPABASE_PUBLIC_URL=.*$|SUPABASE_PUBLIC_URL=${public_url}|" \ -e "s|^API_EXTERNAL_URL=.*$|API_EXTERNAL_URL=${api_url}|" \ -e "s|^SITE_URL=.*$|SITE_URL=${site_url}|" \ -e "s|^PROXY_DOMAIN=.*$|PROXY_DOMAIN=${proxy_domain}|" \ -e "s|^CERTBOT_EMAIL=.*$|CERTBOT_EMAIL=${certbot_email}|" \ .env rm -f .env.old log "Generating secrets and legacy API keys" sh utils/generate-keys.sh --update-env log "Generating asymmetric key pair and opaque API keys" sh utils/add-new-auth-keys.sh --update-env write_version_stamp "$RESOLVED_REF" log "Pulling Docker images" if [ "$NON_INTERACTIVE" = "1" ]; then docker compose --progress quiet pull || warn "docker compose pull failed; you can retry later." else docker compose pull || warn "docker compose pull failed; you can retry later." fi echo "" echo "Setup complete. Project ready at: $(pwd)" echo "" echo "Next steps:" echo " cd $(pwd)" echo " sh run.sh config" echo " sh run.sh secrets" echo " sh run.sh start" echo "" echo "To enable docker-compose overrides (caddy, nginx, logs, rustfs, s3, kong):" echo " sh run.sh config add caddy" echo ""