#!/usr/bin/env bash # # NocoDB Auto-upstall installer. # Single image (community + enterprise). License activates post-install via Admin Panel. # set -euo pipefail if [ "${1:-}" = "--debug" ]; then set -x shift fi # Generated files (db.json, docker.env) hold DB credentials. Create them # owner-only from the start, not just chmod 600 after the fact. umask 077 WORK_DIR="${PWD}/nocodb" # ── Colors ──────────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # ── State (set by prompts or flags) ─────────────────────────────────────────── DOMAIN="" MODE="" # local | production | production-ip ACME_EMAIL="" PG_MODE="" # bundled | external PG_HOST="" # external only; bundled hardcodes "db". Required for external. PG_PORT="5432" PG_DATABASE="nocodb" PG_USER="" # defaulted to "nocodb" on bundled paths. Required for external. PG_PASSWORD="" PG_SSL="" # managed | none | custom PG_CA_FILE="" REDIS_MODE="" # bundled | external REDIS_URL="" # set on bundled paths. Required for external. HOST_PORT="8080" IMAGE_TAG="latest" # docker tag for nocodb/nocodb NON_INTERACTIVE=0 NOCO_SKIP_PREFLIGHT="${NOCO_SKIP_PREFLIGHT:-}" # when set, skip OS/Docker/port checks (testing & re-runs) SELINUX_SUFFIX="" # ":Z" when SELinux is enforcing STACK_STARTED=0 # set to 1 once 'docker compose up -d' succeeds # ── Helpers ─────────────────────────────────────────────────────────────────── header() { printf '\n%b── %s ──────────────────────────────────%b\n' "$BOLD" "$1" "$NC"; } info() { printf ' %s\n' "$1"; } ok() { printf ' %b✓%b %s\n' "$GREEN" "$NC" "$1"; } warn() { printf ' %b!%b %s\n' "$YELLOW" "$NC" "$1"; } fail() { printf ' %bError: %s%b\n' "$RED" "$1" "$NC" >&2; exit 1; } ask() { local prompt="$1" default="${2:-}" if [ -n "$default" ]; then printf ' %s %b[%s]%b: ' "$prompt" "$DIM" "$default" "$NC" else printf ' %s: ' "$prompt" fi read -r REPLY ' read -r REPLY /dev/null \ || curl -s --max-time 3 https://ifconfig.me 2>/dev/null \ || echo "" } # ── Prerequisites ───────────────────────────────────────────────────────────── # Runs on Linux, macOS, and Windows (Git Bash / WSL). We don't install anything # for you: if a dependency is missing, we point you at it and ask you to re-run. check_prereqs() { [ -n "$NOCO_SKIP_PREFLIGHT" ] && return 0 printf '\n%bNocoDB Auto-upstall%b\n' "$BOLD" "$NC" printf '%b═══════════════════════════════════════════%b\n' "$DIM" "$NC" local missing="" if ! command -v docker >/dev/null 2>&1; then missing="${missing} • Docker (https://docs.docker.com/get-docker/)\n" elif ! docker compose version >/dev/null 2>&1; then missing="${missing} • Docker Compose V2 plugin (https://docs.docker.com/compose/install/)\n" fi command -v curl >/dev/null 2>&1 || missing="${missing} • curl\n" if [ -n "$missing" ]; then printf '\n%bMissing required tools:%b\n' "$BOLD" "$NC" printf '%b' "$missing" printf '\nInstall the tool(s) above, then re-run this installer.\n\n' exit 1 fi ok "Docker: $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo unknown)" ok "Compose: $(docker compose version --short 2>/dev/null || echo unknown)" } check_selinux() { if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" = "Enforcing" ]; then SELINUX_SUFFIX=":Z" warn "SELinux Enforcing detected. Applying :Z to bind mounts." fi } check_ports() { [ -n "$NOCO_SKIP_PREFLIGHT" ] && return 0 [ "$MODE" = "production" ] || return 0 for port in 80 443; do if lsof -Pi :"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then warn "Port $port is in use. Free it before continuing or Traefik will fail to bind." fi done } check_existing() { if [ -f "$WORK_DIR/docker-compose.yml" ]; then warn "Existing configuration found at $WORK_DIR/" if [ "$NON_INTERACTIVE" -eq 0 ]; then printf ' Overwrite? [y/N] ' read -r confirm "$WORK_DIR/nocodb/db.json" < "$WORK_DIR/nocodb/db.json" < "$WORK_DIR/docker.env" < "$f" <> "$f" [ "$PG_MODE" = "bundled" ] && cat >> "$f" <<'EOF' db: condition: service_healthy EOF [ "$REDIS_MODE" = "bundled" ] && cat >> "$f" <<'EOF' redis: condition: service_healthy EOF fi cat >> "$f" <> "$f" <> "$f" <> "$f" <> "$f" <> "$f" <> "$f" <> "$f" <> "$f" <<'EOF' networks: nocodb-network: driver: bridge volumes: nocodb_data: EOF [ "$PG_MODE" = "bundled" ] && echo " postgres_data:" >> "$f" [ "$REDIS_MODE" = "bundled" ] && echo " redis_data:" >> "$f" ok "docker-compose.yml" } generate_update_script() { cat > "$WORK_DIR/update.sh" <<'EOF' #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" echo "Pulling latest images..." docker compose pull echo "Restarting services..." docker compose up -d echo "Cleaning up old images..." docker image prune -f echo "Done." EOF chmod +x "$WORK_DIR/update.sh" ok "update.sh" } generate_gitignore() { # Protects secrets if the deploy dir is ever committed to a repo. cat > "$WORK_DIR/.gitignore" <<'EOF' # Credentials. Never commit. docker.env nocodb/db.json # Runtime data (named volumes are managed by Docker, not stored here) letsencrypt/ nocodb/ EOF ok ".gitignore" } tighten_perms() { # Restrict files containing credentials to the deploy user. chmod 600 "$WORK_DIR/docker.env" "$WORK_DIR/nocodb/db.json" 2>/dev/null || true } # ── Flag parsing ────────────────────────────────────────────────────────────── parse_flags() { while [ $# -gt 0 ]; do case "$1" in --non-interactive) NON_INTERACTIVE=1 ;; --quick) NON_INTERACTIVE=1 [ -z "$PG_MODE" ] && PG_MODE="bundled" [ -z "$REDIS_MODE" ] && { REDIS_MODE="bundled"; REDIS_URL="redis://redis:6379"; } ;; --domain=*) DOMAIN="${1#*=}"; NON_INTERACTIVE=1 ;; --acme-email=*) ACME_EMAIL="${1#*=}" ;; --image-tag=*) IMAGE_TAG="${1#*=}" ;; --pg=bundled) PG_MODE="bundled" ;; --pg=external) PG_MODE="external" ;; --pg-host=*) PG_HOST="${1#*=}" ;; --pg-port=*) PG_PORT="${1#*=}" ;; --pg-database=*) PG_DATABASE="${1#*=}" ;; --pg-user=*) PG_USER="${1#*=}" ;; --pg-password=*) PG_PASSWORD="${1#*=}" ;; --pg-ssl=managed) PG_SSL="managed" ;; --pg-ssl=none) PG_SSL="none" ;; --pg-ssl=*) PG_SSL="custom"; PG_CA_FILE="${1#*=}" ;; --redis=bundled) REDIS_MODE="bundled"; REDIS_URL="redis://redis:6379" ;; --redis=external) REDIS_MODE="external" ;; --redis-url=*) REDIS_URL="${1#*=}" ;; --help|-h) cat <}. --pg-ssl must be 'managed', 'none', or a path to an existing CA certificate." fi fi [ -n "$REDIS_MODE" ] || fail "--redis=bundled or --redis=external is required in non-interactive mode" if [ "$REDIS_MODE" = "external" ]; then [ -n "$REDIS_URL" ] || fail "--redis-url is required when --redis=external" fi # In non-interactive mode without --domain, default to local mode (port 8080, no SSL). if [ -z "$DOMAIN" ]; then DOMAIN="" MODE="local" fi # A real domain selects production (Traefik + Let's Encrypt), which needs an email. if [ -n "$DOMAIN" ] && is_valid_domain "$DOMAIN" && [ -z "$ACME_EMAIL" ]; then fail "--acme-email is required when --domain is a domain name (production SSL)" fi } # ── Run ─────────────────────────────────────────────────────────────────────── # Fail fast when prompts are needed but no terminal is attached (e.g. a bare # `curl ... | bash` in a non-interactive context). Interactive prompts read from # /dev/tty, so a piped install still works as long as a terminal is present. require_tty() { [ "$NON_INTERACTIVE" -eq 1 ] && return 0 if ! { true >/dev/tty; } 2>/dev/null; then fail "No terminal available for interactive prompts. Re-run with flags, e.g.: curl -fsSL https://install.nocodb.com/noco.sh | bash -s -- --quick" fi } # Bring the stack up. Skipped under NOCO_SKIP_PREFLIGHT (tests and config-only re-runs). start_stack() { [ -n "$NOCO_SKIP_PREFLIGHT" ] && return 0 header "Starting NocoDB" info "Pulling images and starting containers (first run can take a few minutes)…" if ( cd "$WORK_DIR" && docker compose up -d ); then STACK_STARTED=1 else fail "Configuration is ready, but 'docker compose up -d' failed. Fix the issue above, then run: cd $WORK_DIR && docker compose up -d" fi } # ── Display ─────────────────────────────────────────────────────────────────── display_completion() { echo printf '%b═══════════════════════════════════════════%b\n' "$DIM" "$NC" printf ' %bDone.%b\n\n' "$BOLD" "$NC" if [ "$STACK_STARTED" -eq 1 ]; then if [ "$MODE" = "production" ]; then printf ' %bNocoDB is starting at:%b https://%s\n' "$GREEN" "$NC" "$DOMAIN" printf ' The TLS certificate can take a minute to issue on first run.\n\n' elif [ "$MODE" = "production-ip" ]; then printf ' %bNocoDB is starting at:%b http://%s%b (plaintext, no SSL)%b\n\n' "$GREEN" "$NC" "$DOMAIN" "$YELLOW" "$NC" else printf ' %bNocoDB is starting at:%b http://localhost:%s\n\n' "$GREEN" "$NC" "$HOST_PORT" fi printf ' %bManage the stack:%b\n' "$BOLD" "$NC" printf ' cd %s\n' "$WORK_DIR" printf ' docker compose logs -f nocodb # follow startup logs\n' printf ' docker compose ps # container status\n' printf ' docker compose down # stop (keeps data)\n\n' else printf ' Files generated in: %s/\n' "$WORK_DIR" printf ' docker-compose.yml, docker.env, nocodb/db.json, update.sh, .gitignore\n\n' printf ' %bStart it:%b\n' "$BOLD" "$NC" printf ' cd %s\n' "$WORK_DIR" printf ' docker compose up -d\n' printf ' docker compose logs -f nocodb\n\n' if [ "$MODE" = "production" ]; then printf ' %bNocoDB will be available at:%b https://%s\n\n' "$GREEN" "$NC" "$DOMAIN" elif [ "$MODE" = "production-ip" ]; then printf ' %bNocoDB will be available at:%b http://%s%b (plaintext, no SSL)%b\n\n' "$GREEN" "$NC" "$DOMAIN" "$YELLOW" "$NC" else printf ' %bNocoDB will be available at:%b http://localhost:%s\n\n' "$GREEN" "$NC" "$HOST_PORT" fi fi printf ' %bActivate your license:%b open NocoDB → Admin Panel → License → paste your key\n\n' "$BOLD" "$NC" } # ── Main ────────────────────────────────────────────────────────────────────── main() { parse_flags "$@" apply_bundled_defaults validate_non_interactive require_tty check_prereqs check_selinux check_existing collect_domain determine_mode check_ports collect_pg collect_redis collect_acme_email show_summary echo mkdir -p "$WORK_DIR" generate_db_json generate_env generate_compose generate_update_script generate_gitignore tighten_perms start_stack display_completion } main "$@"