From fcb8ee0412ff9658299059ccc718dd105d3b6bc2 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Fri, 22 May 2026 17:06:58 +0200 Subject: [PATCH] feat(self-hosted): add setup.sh and run.sh scripts for self-hosted supabase (#45603) --- docker/.env.example | 16 ++- docker/CHANGELOG.md | 6 +- docker/run.sh | 297 +++++++++++++++++++++++++++++++++++++++ docker/setup.sh | 330 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 647 insertions(+), 2 deletions(-) create mode 100755 docker/run.sh create mode 100755 docker/setup.sh diff --git a/docker/.env.example b/docker/.env.example index 4dc565da5d2..1f9658cd234 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,3 +1,16 @@ +############ +# Docker compose override files to layer on top of docker-compose.yml. +# Native docker compose COMPOSE_FILE: colon-separated list, base file first. +# Manage with: ./run.sh config add|remove +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +############ +COMPOSE_FILE=docker-compose.yml + + ############ # Secrets # @@ -8,7 +21,8 @@ # https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase # # To generate secrets and API keys: -# sh ./utils/generate-keys.sh +# 1. sh utils/generate-keys.sh +# 2. sh utils/add-new-auth-keys.sh # ############ diff --git a/docker/CHANGELOG.md b/docker/CHANGELOG.md index 66c40af79a8..e4e98cb9820 100644 --- a/docker/CHANGELOG.md +++ b/docker/CHANGELOG.md @@ -13,7 +13,11 @@ See per-service updates below for details. ## Unreleased -⚠️ **Upcoming default changes:** In a future release, several defaults will change: Postgres 15 → 17, Kong → Envoy, MinIO → RustFS, Analytics/Vector removed from the default stack, and the new API keys and authentication replacing the "legacy" architecture. Most of these are already available as optional configurations. +⚠️ **Upcoming changes:** Check the main Supabase [changelog](https://github.com/orgs/supabase/discussions/categories/changelog?discussions_q=is%3Aopen+category%3AChangelog+label%3Aself-hosted) for updates: + +- [Making Analytics and Vector opt-in](https://github.com/orgs/supabase/discussions/46084) +- [Upgrading from PG 15 to 17 (breaking change)](https://github.com/orgs/supabase/discussions/46080) +- [Switching Studio from `supabase_admin` to `postgres` (breaking change)](https://github.com/orgs/supabase/discussions/46081) --- diff --git a/docker/run.sh b/docker/run.sh new file mode 100755 index 00000000000..783777a7a8d --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,297 @@ +#!/bin/sh +# +# Manage the self-hosted Supabase docker compose stack. +# +# Override files are layered via docker compose's native COMPOSE_FILE env +# var in .env. Format: colon-separated list with docker-compose.yml first. +# +# Examples in .env: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +# +# Manage with: ./run.sh config add | config remove +# (accepts either a short name like 'pg17' or 'docker-compose.pg17.yml') +# +# Usage: +# ./run.sh start # docker compose up -d --wait +# ./run.sh stop # docker compose down +# ./run.sh restart [service] # restart the stack (or named services) +# ./run.sh restart --except ... # restart all services except the named ones +# ./run.sh recreate [service] # stop then start (or force-recreate one service) +# ./run.sh recreate --except ... # force-recreate all services except the named ones +# ./run.sh status # docker compose ps +# ./run.sh logs [service] # follow logs (all or one service) +# ./run.sh inspect # docker inspect on a service's container +# ./run.sh printenv # print a service's environment variables +# ./run.sh pull # pull images +# ./run.sh config # show the active COMPOSE_FILE list +# ./run.sh config add # add an override to COMPOSE_FILE in .env +# ./run.sh config remove # remove an override from COMPOSE_FILE in .env +# ./run.sh compose-config # dump fully-resolved docker compose config +# ./run.sh secrets # print key passwords and API keys from .env +# + +set -e + +cd "$(dirname "$0")" + +if [ ! -f docker-compose.yml ]; then + echo "ERROR: docker-compose.yml not found in $(pwd)" >&2 + exit 1 +fi + +# Normalize an override argument: +# pg17 -> docker-compose.pg17.yml +# docker-compose.pg17.yml -> docker-compose.pg17.yml +# ./docker-compose.pg17.yml -> docker-compose.pg17.yml +# docker-compose.yml -> error (base file, always implicit) +normalize_override() { + arg="${1#./}" + case "$arg" in + docker-compose.yml) + echo "ERROR: docker-compose.yml is the base file, always included" >&2 + return 1 + ;; + docker-compose.*.yml) + echo "$arg" + ;; + *) + echo "docker-compose.${arg}.yml" + ;; + esac +} + +# Read COMPOSE_FILE from .env (stripping quotes and CR). +read_compose_file() { + [ -f .env ] || return 0 + grep '^COMPOSE_FILE=' .env | head -n1 | cut -d= -f2- | tr -d "\r\"'" +} + +# Pretty-print the effective compose file list. +print_config() { + val="$1" + [ -z "$val" ] && val="docker-compose.yml" + echo "COMPOSE_FILE=$val" + echo "compose files:" + OLD_IFS=$IFS + IFS=: + for f in $val; do + echo " $f" + done + IFS=$OLD_IFS + echo "" +} + +# Update or append COMPOSE_FILE in .env. +write_compose_file() { + new_value="$1" + if [ ! -f .env ]; then + echo "ERROR: .env not found in $(pwd)" >&2 + exit 1 + fi + new_line="COMPOSE_FILE=$new_value" + if grep -q '^COMPOSE_FILE=' .env; then + sed -i.old -e "s|^COMPOSE_FILE=.*$|$new_line|" .env + rm -f .env.old + else + cat >> .env <. +# +# Examples: +# COMPOSE_FILE=docker-compose.yml +# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml +############ +$new_line +EOF + fi +} + +# Echoes the list of services (one per line) minus those passed as args. +# Warns on unknown names; returns 1 if no services remain. +services_except() { + all_services=$(docker compose config --services) + filtered="$all_services" + for ex in "$@"; do + echo "$all_services" | grep -qFx "$ex" \ + || echo "Warning: '$ex' is not a service in this project" >&2 + filtered=$(echo "$filtered" | grep -vFx "$ex" || true) + done + if [ -z "$filtered" ]; then + echo "No services left after applying --except" >&2 + return 1 + fi + printf '%s\n' "$filtered" +} + +CMD="${1:-help}" +[ "$#" -gt 0 ] && shift + +case "$CMD" in + start|up) + exec docker compose up -d --wait "$@" + ;; + stop|down) + exec docker compose down "$@" + ;; + restart) + if [ "${1:-}" = "--except" ]; then + shift + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") restart --except ..." >&2; exit 1; } + services=$(services_except "$@") || exit 1 + # shellcheck disable=SC2086 + exec docker compose restart $services + fi + exec docker compose restart "$@" + ;; + recreate) + if [ "${1:-}" = "--except" ]; then + shift + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") recreate --except ..." >&2; exit 1; } + services=$(services_except "$@") || exit 1 + # shellcheck disable=SC2086 + exec docker compose up -d --wait --force-recreate --no-deps $services + fi + if [ $# -eq 0 ]; then + docker compose down + exec docker compose up -d --wait + fi + # Single-service recreate: force-recreate the named services only, + # leave their dependencies running. + exec docker compose up -d --wait --force-recreate --no-deps "$@" + ;; + status|ps) + exec docker compose ps "$@" + ;; + logs) + exec docker compose logs -f "$@" + ;; + inspect) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") inspect [docker-inspect-args]" >&2; exit 1; } + svc="$1"; shift + cid=$(docker compose ps -q "$svc") + [ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; } + exec docker inspect "$cid" "$@" + ;; + printenv) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") printenv " >&2; exit 1; } + svc="$1" + cid=$(docker compose ps -q "$svc") + [ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; } + exec docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$cid" + ;; + pull) + exec docker compose pull "$@" + ;; + compose-config) + exec docker compose config "$@" + ;; + config) + sub="${1:-show}" + [ "$#" -gt 0 ] && shift + current=$(read_compose_file) + case "$sub" in + show) + print_config "$current" + ;; + add) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") config add ..." >&2; exit 1; } + new_value="${current:-docker-compose.yml}" + changed=false + for arg in "$@"; do + file=$(normalize_override "$arg") || exit 1 + if [ ! -f "$file" ]; then + echo "ERROR: $file not found" >&2 + exit 1 + fi + case ":$new_value:" in + *":$file:"*) echo "Already present: $file" ;; + *) new_value="$new_value:$file"; changed=true ;; + esac + done + [ "$changed" = true ] && write_compose_file "$new_value" + print_config "$new_value" + ;; + remove|rm) + [ $# -eq 0 ] && { echo "Usage: $(basename "$0") config remove ..." >&2; exit 1; } + new_value="${current:-docker-compose.yml}" + changed=false + for arg in "$@"; do + file=$(normalize_override "$arg") || exit 1 + case ":$new_value:" in + *":$file:"*) + # Drop $file by rebuilding the colon list + tmp="" + OLD_IFS=$IFS + IFS=: + for tok in $new_value; do + [ "$tok" = "$file" ] || tmp="${tmp:+$tmp:}$tok" + done + IFS=$OLD_IFS + new_value="$tmp" + changed=true + ;; + *) echo "Not present: $file" ;; + esac + done + [ "$changed" = true ] && write_compose_file "$new_value" + print_config "$new_value" + ;; + *) + echo "Unknown config subcommand: $sub" >&2 + echo "Use: config | config add ... | config remove ..." >&2 + exit 1 + ;; + esac + ;; + secrets) + if [ ! -f .env ]; then + echo "ERROR: .env not found in $(pwd)" >&2 + exit 1 + fi + for var in POSTGRES_PASSWORD DASHBOARD_PASSWORD \ + SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY \ + S3_PROTOCOL_ACCESS_KEY_ID S3_PROTOCOL_ACCESS_KEY_SECRET; do + line=$(grep "^${var}=" .env | head -n1) + if [ -n "$line" ]; then + echo "$line" + else + echo "${var}=" + fi + done + echo "" + ;; + help|-h|--help) + cat < + +Commands: + start Start the stack (docker compose up -d --wait) + stop Stop the stack (docker compose down) + restart [service] Restart the stack (or named services) + restart --except ... + Restart all services except the named ones + recreate [service] Stop then start, or force-recreate one service (--no-deps) + recreate --except ... + Force-recreate all services except the named ones (--no-deps) + status Show service status + logs [service] Follow logs (optionally for a single service) + inspect Inspect a service's container (forwards extra args to docker inspect) + printenv Print a service's environment variables (one per line) + pull Pull all images + config Show the active COMPOSE_FILE list + config add Add an override to COMPOSE_FILE in .env (short name or full filename) + config remove Remove an override from COMPOSE_FILE in .env + compose-config Dump the fully-resolved docker compose config + secrets Show key passwords and API keys from .env + +EOF + ;; + *) + echo "Unknown command: $CMD" >&2 + echo "Run '$0 help' for usage." >&2 + exit 1 + ;; +esac diff --git a/docker/setup.sh b/docker/setup.sh new file mode 100755 index 00000000000..e8d91f0ac6c --- /dev/null +++ b/docker/setup.sh @@ -0,0 +1,330 @@ +#!/bin/sh +# +# Bootstrap a self-hosted Supabase project on Linux (Debian/Ubuntu or RHEL/CentOS/Fedora). +# +# What it does: +# 1. Installs prerequisites: git, curl, 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. Prompts for the main URLs and writes them to .env +# 7. 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 +# +# curl -fsSL | sh # bootstrap from scratch in CWD +# + +set -e + +PROJECT_DIR="supabase-project" +SKIP_DEPS=0 +WITH_AWS=0 +ASSUME_YES=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 + -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 ;; + -y|--yes) ASSUME_YES=1; shift ;; + -h|--help) print_help; exit 0 ;; + *) echo "Unknown option: $1" >&2; print_help; exit 1 ;; + esac +done + +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 [ "$ASSUME_YES" = "1" ] || ! { : > /dev/tty; } 2>/dev/null; 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" +} + +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, curl, openssl, jq, ca-certificates" + pkg_update + if [ "$OS_FAMILY" = "debian" ]; then + pkg_install git curl openssl jq ca-certificates \ + apt-transport-https gnupg lsb-release + else + pkg_install git curl 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 + 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 + + 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="" + +prepare_source() { + log "Sparse-cloning supabase repo" + SRC_TMP=$(mktemp -d) || return 1 + git clone --filter=blob:none --no-checkout --depth=1 --quiet \ + https://github.com/supabase/supabase "$SRC_TMP/supabase" 2>/dev/null || \ + { rm -rf "$SRC_TMP"; return 1; } + + cd "$SRC_TMP/supabase" || { rm -rf "$SRC_TMP"; return 1; } + git sparse-checkout init --cone && \ + git sparse-checkout set docker && \ + git checkout --quiet 2>/dev/null + SRC_DIR="$PWD/docker" + cd - > /dev/null +} + +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- +} + +# --- 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_api_url=$(read_env API_EXTERNAL_URL) +current_site_url=$(read_env SITE_URL) + +[ -z "$current_public_url" ] && current_public_url="http://localhost:8000" +[ -z "$current_api_url" ] && current_api_url="$current_public_url" +[ -z "$current_site_url" ] && current_site_url="http://localhost:3000" + +if [ "$ASSUME_YES" = "1" ] || ! { : > /dev/tty; } 2>/dev/null; then + log "Non-interactive: keeping default URLs (edit .env to change)" +else + echo "" + echo "Configure the main URLs (press Enter to accept the default)." + echo "" +fi + +public_url=$(ask "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url") +api_url=$(ask "API_EXTERNAL_URL (Auth callbacks)" "$public_url") +site_url=$(ask "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 + +log "Pulling Docker images" +docker compose pull || warn "docker compose pull failed; you can retry later." + +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 (pg17, envoy, caddy, nginx, rustfs, s3, logs):" +echo " sh ./run.sh config add pg17" +echo ""